feat(proxy): add Azure AI Speech pass-through route

Adds /azure_speech/{endpoint:path}, an authenticated pass-through for the Azure AI Speech REST APIs: short-audio recognition on <region>.stt.speech.microsoft.com and batch transcription on <region>.api.cognitive.microsoft.com. The proxy resolves the subscription key through PassthroughEndpointRouter (AZURE_SPEECH_API_KEY or an Admin UI credential), picks the host from AZURE_SPEECH_REGION or AZURE_SPEECH_API_BASE, injects Ocp-Apim-Subscription-Key, strips the caller's Authorization and subscription-key headers, forwards the raw audio body byte for byte, and records a zero-cost SpendLogs row tagged azure_speech since the price map has no Azure Speech STT entry

Resolves LIT-7939

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yassin 2026-09-17 03:03:19 +00:00
parent 351a54e849
commit 2e8dc0a627
23 changed files with 1177 additions and 6 deletions

View file

@ -82,6 +82,7 @@ GATEWAY_PATH_PREFIXES: tuple[str, ...] = (
"/anthropic/",
"/azure/",
"/azure_ai/",
"/azure_speech/",
"/aws/",
"/bedrock/",
"/comprehendmedical",

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" "/azure_speech" "/aws" "/bedrock" "/comprehendmedical" "/cohere" "/gemini" "/google"
"/vertex_ai" "/vertex-ai" "/assemblyai" "/eu.assemblyai" "/langfuse" "/vllm"
"/mistral" "/groq" "/voyage" "/cursor" "/milvus" "/openai_passthrough"
"/toolset"

View file

@ -1570,6 +1570,16 @@ ALLOWED_VERTEX_AI_PASSTHROUGH_HEADERS: Final = {
# Works for all LLM pass-through endpoints (Vertex AI, Anthropic, Bedrock, etc.)
PASS_THROUGH_HEADER_PREFIX: Final = "x-pass-"
AZURE_SPEECH_CUSTOM_LLM_PROVIDER: Final = "azure_speech"
AZURE_SPEECH_PASS_THROUGH_ROUTE_PREFIX: Final = "/azure_speech"
AZURE_SPEECH_SHORT_AUDIO_PATH_PREFIX: Final = "/speech/"
AZURE_SPEECH_BATCH_PATH_PREFIX: Final = "/speechtotext/"
AZURE_SPEECH_STT_DOMAIN: Final = "stt.speech.microsoft.com"
AZURE_SPEECH_COGNITIVE_SERVICES_DOMAIN: Final = "api.cognitive.microsoft.com"
AZURE_SPEECH_SUBSCRIPTION_KEY_HEADER: Final = "Ocp-Apim-Subscription-Key"
AZURE_SPEECH_SHORT_AUDIO_MODEL: Final = "short-audio"
AZURE_SPEECH_BATCH_MODEL: Final = "batch-transcription"
BASE_MCP_ROUTE: Final = "/mcp"
BATCH_STATUS_POLL_INTERVAL_SECONDS: Final = int(os.getenv("BATCH_STATUS_POLL_INTERVAL_SECONDS", 3600)) # 1 hour

View file

@ -17,6 +17,7 @@ _PASS_THROUGH_PROTECTED_HEADERS: Final[frozenset] = frozenset(
"api-key",
"x-api-key",
"x-goog-api-key",
"ocp-apim-subscription-key",
"host",
"content-length",
"accept-encoding",

View file

@ -196,6 +196,7 @@ LAZY_FEATURES: Final[tuple[LazyFeature, ...]] = (
"/assemblyai/",
"/azure/",
"/azure_ai/",
"/azure_speech/",
"/bedrock/",
"/cohere/",
"/comprehendmedical",

View file

@ -17133,6 +17133,228 @@
]
}
},
"/azure_speech/{endpoint}": {
"delete": {
"description": "Pass-through for the Azure AI Speech REST APIs (speech to text), e.g.\n`POST /azure_speech/speech/recognition/conversation/cognitiveservices/v1?language=en-US`\nwith the raw audio as the body, or `POST /azure_speech/speechtotext/v3.2/transcriptions`.\n\nThe body is forwarded byte for byte and the proxy injects its own\n`Ocp-Apim-Subscription-Key`; the caller's `Authorization` header is the LiteLLM key\nand is never forwarded.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/azure_speech)",
"operationId": "azure_speech_proxy_route_azure_speech__endpoint__delete",
"parameters": [
{
"in": "path",
"name": "endpoint",
"required": true,
"schema": {
"title": "Endpoint",
"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": "Azure Speech Proxy Route",
"tags": [
"llm_passthrough"
]
},
"get": {
"description": "Pass-through for the Azure AI Speech REST APIs (speech to text), e.g.\n`POST /azure_speech/speech/recognition/conversation/cognitiveservices/v1?language=en-US`\nwith the raw audio as the body, or `POST /azure_speech/speechtotext/v3.2/transcriptions`.\n\nThe body is forwarded byte for byte and the proxy injects its own\n`Ocp-Apim-Subscription-Key`; the caller's `Authorization` header is the LiteLLM key\nand is never forwarded.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/azure_speech)",
"operationId": "azure_speech_proxy_route_azure_speech__endpoint__get",
"parameters": [
{
"in": "path",
"name": "endpoint",
"required": true,
"schema": {
"title": "Endpoint",
"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": "Azure Speech Proxy Route",
"tags": [
"llm_passthrough"
]
},
"patch": {
"description": "Pass-through for the Azure AI Speech REST APIs (speech to text), e.g.\n`POST /azure_speech/speech/recognition/conversation/cognitiveservices/v1?language=en-US`\nwith the raw audio as the body, or `POST /azure_speech/speechtotext/v3.2/transcriptions`.\n\nThe body is forwarded byte for byte and the proxy injects its own\n`Ocp-Apim-Subscription-Key`; the caller's `Authorization` header is the LiteLLM key\nand is never forwarded.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/azure_speech)",
"operationId": "azure_speech_proxy_route_azure_speech__endpoint__patch",
"parameters": [
{
"in": "path",
"name": "endpoint",
"required": true,
"schema": {
"title": "Endpoint",
"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": "Azure Speech Proxy Route",
"tags": [
"llm_passthrough"
]
},
"post": {
"description": "Pass-through for the Azure AI Speech REST APIs (speech to text), e.g.\n`POST /azure_speech/speech/recognition/conversation/cognitiveservices/v1?language=en-US`\nwith the raw audio as the body, or `POST /azure_speech/speechtotext/v3.2/transcriptions`.\n\nThe body is forwarded byte for byte and the proxy injects its own\n`Ocp-Apim-Subscription-Key`; the caller's `Authorization` header is the LiteLLM key\nand is never forwarded.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/azure_speech)",
"operationId": "azure_speech_proxy_route_azure_speech__endpoint__post",
"parameters": [
{
"in": "path",
"name": "endpoint",
"required": true,
"schema": {
"title": "Endpoint",
"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": "Azure Speech Proxy Route",
"tags": [
"llm_passthrough"
]
},
"put": {
"description": "Pass-through for the Azure AI Speech REST APIs (speech to text), e.g.\n`POST /azure_speech/speech/recognition/conversation/cognitiveservices/v1?language=en-US`\nwith the raw audio as the body, or `POST /azure_speech/speechtotext/v3.2/transcriptions`.\n\nThe body is forwarded byte for byte and the proxy injects its own\n`Ocp-Apim-Subscription-Key`; the caller's `Authorization` header is the LiteLLM key\nand is never forwarded.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/azure_speech)",
"operationId": "azure_speech_proxy_route_azure_speech__endpoint__put",
"parameters": [
{
"in": "path",
"name": "endpoint",
"required": true,
"schema": {
"title": "Endpoint",
"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": "Azure Speech Proxy Route",
"tags": [
"llm_passthrough"
]
}
},
"/bedrock/{endpoint}": {
"delete": {
"description": "This is the v1 passthrough for Bedrock.\nV2 is handled by the `/bedrock/v2` endpoint.\n[Docs](https://docs.litellm.ai/docs/pass_through/bedrock)",

View file

@ -468,6 +468,7 @@ class LiteLLMRoutes(enum.Enum):
mapped_pass_through_routes = [
"/bedrock",
"/comprehendmedical",
"/azure_speech",
"/vertex-ai",
"/vertex_ai",
"/cohere",

View file

@ -105,6 +105,7 @@ from litellm.proxy.common_utils.http_parsing_utils import (
_safe_get_request_headers,
_safe_get_request_query_params,
_safe_set_request_parsed_body,
is_opaque_audio_pass_through_request,
populate_request_with_path_params,
read_raw_json_body,
rewrite_request_model,
@ -1354,6 +1355,12 @@ async def _read_request_body_deferring_parse_failure(
must run (resolving identity onto the request's trace) before the 400 goes
out; the caller re-raises the returned exception once identity is seeded.
"""
if is_opaque_audio_pass_through_request(
route=get_request_route(request=request),
content_type=_safe_get_request_headers(request=request).get("content-type", ""),
):
_safe_set_request_parsed_body(request=request, parsed_body={}) # mutable-ok: the body cache stores a plain dict
return {}, None # mutable-ok: request_data is a plain dict across the whole auth path
try:
parsed_body: Final = await _read_request_body(request=request)
except ProxyException as parse_exception:

View file

@ -9,7 +9,12 @@ from fastapi import Request, UploadFile, status
from typing_extensions import NotRequired, ReadOnly, Required
from litellm._logging import verbose_proxy_logger
from litellm.constants import CLIENT_REQUESTED_MODEL_SCOPE_KEY, MAX_REQUEST_BODY_SIZE_TO_REPAIR_MB
from litellm.constants import (
AZURE_SPEECH_PASS_THROUGH_ROUTE_PREFIX,
AZURE_SPEECH_SHORT_AUDIO_PATH_PREFIX,
CLIENT_REQUESTED_MODEL_SCOPE_KEY,
MAX_REQUEST_BODY_SIZE_TO_REPAIR_MB,
)
from litellm.proxy._types import ProxyException
from litellm.proxy.common_utils.callback_utils import (
get_metadata_variable_name_from_kwargs,
@ -214,6 +219,12 @@ async def _read_request_body(request: Request | None) -> dict:
return {}
def is_opaque_audio_pass_through_request(route: str, content_type: str) -> bool:
return route.startswith(
f"{AZURE_SPEECH_PASS_THROUGH_ROUTE_PREFIX}{AZURE_SPEECH_SHORT_AUDIO_PATH_PREFIX}"
) and _normalize_media_type(content_type).startswith("audio/")
async def read_raw_json_body(request: Request | None) -> bytes | None:
if request is None or _safe_get_request_parsed_body(request=request) is None:
return None

View file

@ -30,6 +30,13 @@ from litellm import get_llm_provider
from litellm._logging import verbose_proxy_logger
from litellm.constants import (
ALLOWED_VERTEX_AI_PASSTHROUGH_HEADERS,
AZURE_SPEECH_BATCH_PATH_PREFIX,
AZURE_SPEECH_COGNITIVE_SERVICES_DOMAIN,
AZURE_SPEECH_CUSTOM_LLM_PROVIDER,
AZURE_SPEECH_PASS_THROUGH_ROUTE_PREFIX,
AZURE_SPEECH_SHORT_AUDIO_PATH_PREFIX,
AZURE_SPEECH_STT_DOMAIN,
AZURE_SPEECH_SUBSCRIPTION_KEY_HEADER,
BEDROCK_AGENT_RUNTIME_PASS_THROUGH_ROUTES,
)
from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix
@ -1316,6 +1323,117 @@ async def comprehend_medical_sdk_proxy_route(
)
AZURE_SPEECH_FORWARDED_REQUEST_HEADERS: Final = ("content-type", "accept")
AZURE_SPEECH_ENDPOINT_FAMILY_DOMAINS: Final = MappingProxyType(
{
AZURE_SPEECH_SHORT_AUDIO_PATH_PREFIX: AZURE_SPEECH_STT_DOMAIN,
AZURE_SPEECH_BATCH_PATH_PREFIX: AZURE_SPEECH_COGNITIVE_SERVICES_DOMAIN,
}
)
def resolve_azure_speech_base_url(endpoint_path: str, api_base: str | None, region: str | None) -> httpx.URL | None:
"""
Azure AI Speech serves the two REST families from different regional hosts: short-audio
recognition under ``{region}.stt.speech.microsoft.com`` and batch transcription under
``{region}.api.cognitive.microsoft.com``. An operator-configured ``api_base`` (custom
domain or private endpoint) serves both and wins over the region. Returns ``None`` when
the path is outside both families so the operator key is never sent for an unknown API.
"""
domain: Final = next(
(
family_domain
for family_prefix, family_domain in AZURE_SPEECH_ENDPOINT_FAMILY_DOMAINS.items()
if endpoint_path.startswith(family_prefix)
),
None,
)
if domain is None:
return None
if api_base:
return httpx.URL(api_base)
if not region:
return None
return httpx.URL(f"https://{region}.{domain}")
@router.api_route(
f"{AZURE_SPEECH_PASS_THROUGH_ROUTE_PREFIX}/{{endpoint:path}}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH"], # mutable-ok: fastapi route methods must be a list
tags=["Azure AI Speech Pass-through", "pass-through"], # mutable-ok: fastapi route tags must be a list
)
async def azure_speech_proxy_route(
endpoint: str,
request: Request,
fastapi_response: Response,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
):
"""
Pass-through for the Azure AI Speech REST APIs (speech to text), e.g.
`POST /azure_speech/speech/recognition/conversation/cognitiveservices/v1?language=en-US`
with the raw audio as the body, or `POST /azure_speech/speechtotext/v3.2/transcriptions`.
The body is forwarded byte for byte and the proxy injects its own
`Ocp-Apim-Subscription-Key`; the caller's `Authorization` header is the LiteLLM key
and is never forwarded.
[Docs](https://docs.litellm.ai/docs/pass_through/azure_speech)
"""
endpoint_path: Final = httpx.URL(endpoint).path
normalized_endpoint_path: Final = endpoint_path if endpoint_path.startswith("/") else f"/{endpoint_path}"
base_url: Final = resolve_azure_speech_base_url(
endpoint_path=normalized_endpoint_path,
api_base=get_secret_str(secret_name="AZURE_SPEECH_API_BASE"),
region=get_secret_str(secret_name="AZURE_SPEECH_REGION"),
)
if base_url is None:
raise HTTPException(
status_code=400,
detail=(
f"Unsupported Azure Speech path: {normalized_endpoint_path}. Supported prefixes are "
f"{AZURE_SPEECH_SHORT_AUDIO_PATH_PREFIX} and {AZURE_SPEECH_BATCH_PATH_PREFIX}; set "
"AZURE_SPEECH_REGION or AZURE_SPEECH_API_BASE in the proxy environment."
),
)
azure_speech_api_key: Final = passthrough_endpoint_router.get_credentials(
custom_llm_provider=AZURE_SPEECH_CUSTOM_LLM_PROVIDER,
region_name=None,
)
if azure_speech_api_key is None:
raise HTTPException(
status_code=400,
detail="Azure Speech credentials not found. Set AZURE_SPEECH_API_KEY in the proxy environment.",
)
target_url: Final = base_url.copy_with(
path=HttpPassThroughEndpointHelpers.join_base_and_endpoint_path(base_url, normalized_endpoint_path)
)
request_headers: Final = _safe_get_request_headers(request)
upstream_headers: Final = MappingProxyType(
{
header_name: header_value
for header_name, header_value in (
*(
(header_name, request_headers[header_name])
for header_name in AZURE_SPEECH_FORWARDED_REQUEST_HEADERS
if header_name in request_headers
),
(AZURE_SPEECH_SUBSCRIPTION_KEY_HEADER, azure_speech_api_key),
)
}
)
raw_body: Final = await request.body()
endpoint_func: Final = create_pass_through_route(
endpoint=endpoint,
target=str(target_url),
custom_headers=upstream_headers,
custom_llm_provider=AZURE_SPEECH_CUSTOM_LLM_PROVIDER,
)
setattr(request.state, LITELLM_PASS_THROUGH_RAW_BODY_STATE_KEY, raw_body)
return await endpoint_func(request, fastapi_response, user_api_key_dict)
def _resolve_vertex_model_from_router(
model_id: str,
llm_router: litellm.Router | None,

View file

@ -0,0 +1,84 @@
from collections.abc import Mapping
from datetime import datetime
from typing import Final
from urllib.parse import urlparse
import httpx
from litellm._logging import verbose_proxy_logger
from litellm.constants import (
AZURE_SPEECH_BATCH_MODEL,
AZURE_SPEECH_CUSTOM_LLM_PROVIDER,
AZURE_SPEECH_SHORT_AUDIO_MODEL,
AZURE_SPEECH_SHORT_AUDIO_PATH_PREFIX,
)
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.proxy._types import PassThroughEndpointLoggingTypedDict
from litellm.types.utils import StandardPassThroughResponseObject
class AzureSpeechPassthroughLoggingHandler:
@staticmethod
def _model_from_url_route(url_route: str) -> str:
path: Final = urlparse(url_route).path
if path.startswith(AZURE_SPEECH_SHORT_AUDIO_PATH_PREFIX):
return f"{AZURE_SPEECH_CUSTOM_LLM_PROVIDER}/{AZURE_SPEECH_SHORT_AUDIO_MODEL}"
return f"{AZURE_SPEECH_CUSTOM_LLM_PROVIDER}/{AZURE_SPEECH_BATCH_MODEL}"
@staticmethod
def azure_speech_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],
**kwargs: object, # kwargs-ok: the passthrough logging dispatch forwards shared logging kwargs to every handler
) -> PassThroughEndpointLoggingTypedDict:
"""
Records model and provider for an Azure AI Speech REST call. Azure bills per audio
hour after the fact and neither the short-audio response nor the batch job carries
a billable duration this path can trust, so response_cost is recorded as 0.0 rather
than estimated.
"""
try:
model_name: Final = AzureSpeechPassthroughLoggingHandler._model_from_url_route(url_route)
updated_kwargs: Final = { # mutable-ok: the logging pipeline requires a plain kwargs dict
**kwargs,
"model": model_name,
"custom_llm_provider": AZURE_SPEECH_CUSTOM_LLM_PROVIDER,
"response_cost": 0.0,
}
logging_obj.model_call_details.update(
model=model_name,
custom_llm_provider=AZURE_SPEECH_CUSTOM_LLM_PROVIDER,
response_cost=0.0,
)
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 Azure Speech passthrough logging handler: %s", e)
fallback_payload: Final[PassThroughEndpointLoggingTypedDict] = {
"result": StandardPassThroughResponseObject(response=result),
"kwargs": kwargs,
}
return fallback_payload
return handler_payload

View file

@ -52,6 +52,7 @@ from litellm.litellm_core_utils.core_helpers import (
from litellm.litellm_core_utils.initialize_dynamic_callback_params import validate_no_callback_env_reference
from litellm.litellm_core_utils.internal_call_metadata import MODEL_ACCESS_GROUP_METADATA_KEY
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.litellm_logging import _get_masked_values
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.llms.base_llm.managed_resources.utils import (
@ -1023,7 +1024,7 @@ async def pass_through_request(
verbose_proxy_logger.debug(
"Pass through endpoint sending request to \nURL %s\nheaders: %s\nbody: %s\n",
url,
upstream_headers,
_get_masked_values(upstream_headers),
_parsed_body,
)

View file

@ -5,6 +5,7 @@ from urllib.parse import urlparse
import httpx
from litellm.constants import AZURE_SPEECH_CUSTOM_LLM_PROVIDER
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.proxy._types import PassThroughEndpointLoggingResultValues
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
@ -256,6 +257,24 @@ 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_azure_speech_route(custom_llm_provider):
from .llm_provider_handlers.azure_speech_passthrough_logging_handler import (
AzureSpeechPassthroughLoggingHandler,
)
azure_speech_handler_result: Final = AzureSpeechPassthroughLoggingHandler.azure_speech_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 = azure_speech_handler_result["result"] # rebind-ok: elif-chain
kwargs = azure_speech_handler_result["kwargs"] # rebind-ok: elif-chain contract
elif self.is_vertex_ai_live_route(url_route):
from .llm_provider_handlers.vertex_ai_live_passthrough_logging_handler import (
VertexAILivePassthroughLoggingHandler,
@ -300,7 +319,7 @@ class PassThroughEndpointLogging:
):
standard_logging_response_object: PassThroughEndpointLoggingResultValues | None = None
logging_obj.model_call_details["passthrough_logging_payload"] = passthrough_logging_payload
if self.is_assemblyai_route(url_route):
if self.is_assemblyai_route(url_route) and not self.is_azure_speech_route(custom_llm_provider):
if AssemblyAIPassthroughLoggingHandler._should_log_request(httpx_response.request.method) is not True:
return
self.assemblyai_passthrough_logging_handler.assemblyai_passthrough_logging_handler(
@ -389,6 +408,9 @@ class PassThroughEndpointLogging:
def is_comprehend_medical_route(self, custom_llm_provider: str | None) -> bool:
return custom_llm_provider == "comprehendmedical"
def is_azure_speech_route(self, custom_llm_provider: str | None) -> bool:
return custom_llm_provider == AZURE_SPEECH_CUSTOM_LLM_PROVIDER
def is_langfuse_route(self, url_route: str):
parsed_url: Final = urlparse(url_route)
for route in self.TRACKED_LANGFUSE_ROUTES:

View file

@ -586,6 +586,24 @@
],
"default_model_placeholder": "azure_ai/command-r-plus"
},
{
"provider": "Azure_Speech",
"provider_display_name": "Azure AI Speech",
"litellm_provider": "azure_speech",
"credential_fields": [
{
"key": "api_key",
"label": "Azure AI Speech Subscription Key",
"placeholder": null,
"tooltip": "The Ocp-Apim-Subscription-Key for your Azure AI Speech resource. The proxy injects it on every /azure_speech/* pass-through request. Region and API base come from AZURE_SPEECH_REGION / AZURE_SPEECH_API_BASE",
"required": true,
"field_type": "password",
"options": null,
"default_value": null
}
],
"default_model_placeholder": "azure_speech/short-audio"
},
{
"provider": "AZURE_TEXT",
"provider_display_name": "Azure Text",

View file

@ -4060,6 +4060,7 @@ class LlmProviders(str, Enum):
TOPAZ = "topaz"
SAP_GENERATIVE_AI_HUB = "sap"
ASSEMBLYAI = "assemblyai"
AZURE_SPEECH = "azure_speech"
CHARITY_ENGINE = "charity_engine"
GITHUB_COPILOT = "github_copilot"
SNOWFLAKE = "snowflake"

View file

@ -86,7 +86,7 @@ locals {
"/queue/chat/*",
"/v1beta/*",
"/interactions/*",
"/anthropic/*", "/azure/*", "/azure_ai/*", "/aws/*", "/bedrock/*", "/comprehendmedical*",
"/anthropic/*", "/azure/*", "/azure_ai/*", "/azure_speech/*", "/aws/*", "/bedrock/*", "/comprehendmedical*",
"/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/*", "/azure_speech/*", "/aws/*", "/bedrock/*", "/comprehendmedical*",
"/cohere/*", "/gemini/*", "/google/*",
"/vertex_ai/*", "/vertex-ai/*",
"/assemblyai/*", "/eu.assemblyai/*",

View file

@ -116,6 +116,11 @@ 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")),
(
"/azure_speech/speech/recognition/conversation/cognitiveservices/v1",
(BillableCategory.LLM, "/azure_speech"),
),
("/azure_speech/speechtotext/v3.2/transcriptions", (BillableCategory.LLM, "/azure_speech")),
("/mcp", (BillableCategory.MCP, "/mcp")),
("/mcp/", (BillableCategory.MCP, "/mcp")),
("/mcp/tools/list", (BillableCategory.MCP, "/mcp")),

View file

@ -0,0 +1,123 @@
from datetime import datetime
from unittest.mock import MagicMock
import httpx
import pytest
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.azure_speech_passthrough_logging_handler import (
AzureSpeechPassthroughLoggingHandler,
)
from litellm.proxy.pass_through_endpoints.success_handler import (
PassThroughEndpointLogging,
)
SHORT_AUDIO_URL = "https://eastus.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US"
BATCH_URL = "https://eastus.api.cognitive.microsoft.com/speechtotext/v3.2/transcriptions"
TRANSCRIPT = '{"RecognitionStatus":"Success","DisplayText":"Hello world."}'
def _make_response(url: str) -> httpx.Response:
request = httpx.Request("POST", url, headers={"Ocp-Apim-Subscription-Key": "server-secret"})
return httpx.Response(200, request=request, text=TRANSCRIPT)
def _make_logging_obj() -> MagicMock:
logging_obj = MagicMock()
logging_obj.litellm_call_id = "test-call-id"
logging_obj.model_call_details = {}
return logging_obj
class TestAzureSpeechPassthroughHandler:
@pytest.mark.parametrize(
"url_route,expected_model",
[
(SHORT_AUDIO_URL, "azure_speech/short-audio"),
(BATCH_URL, "azure_speech/batch-transcription"),
(f"{BATCH_URL}/8a5d3f2c-0b1e-4c7d-9e6f-1234567890ab/files", "azure_speech/batch-transcription"),
],
)
def test_records_model_provider_and_zero_cost(self, url_route: str, expected_model: str):
logging_obj = _make_logging_obj()
handler_result = AzureSpeechPassthroughLoggingHandler.azure_speech_passthrough_handler(
httpx_response=_make_response(url_route),
logging_obj=logging_obj,
url_route=url_route,
result=TRANSCRIPT,
start_time=datetime.now(),
end_time=datetime.now(),
cache_hit=False,
request_body={},
)
assert handler_result["result"] == {"response": TRANSCRIPT}
assert handler_result["kwargs"]["model"] == expected_model
assert handler_result["kwargs"]["custom_llm_provider"] == "azure_speech"
assert handler_result["kwargs"]["response_cost"] == 0.0
assert handler_result["kwargs"]["standard_logging_object"]["response_cost"] == 0.0
assert handler_result["kwargs"]["standard_logging_object"]["model"] == expected_model
assert logging_obj.model_call_details["model"] == expected_model
assert logging_obj.model_call_details["custom_llm_provider"] == "azure_speech"
assert logging_obj.model_call_details["response_cost"] == 0.0
def test_subscription_key_never_reaches_the_logging_payload(self):
handler_result = AzureSpeechPassthroughLoggingHandler.azure_speech_passthrough_handler(
httpx_response=_make_response(SHORT_AUDIO_URL),
logging_obj=_make_logging_obj(),
url_route=SHORT_AUDIO_URL,
result=TRANSCRIPT,
start_time=datetime.now(),
end_time=datetime.now(),
cache_hit=False,
request_body={},
)
assert "server-secret" not in repr(handler_result)
class TestIsAzureSpeechRoute:
def test_matches_by_provider_tag(self):
assert PassThroughEndpointLogging().is_azure_speech_route("azure_speech")
@pytest.mark.parametrize("provider", ["azure", "azure_ai", "comprehendmedical", None])
def test_does_not_match_other_providers(self, provider: str | None):
assert not PassThroughEndpointLogging().is_azure_speech_route(provider)
def test_config_driven_passthrough_to_azure_speech_host_is_not_claimed(self):
normalized = PassThroughEndpointLogging().normalize_llm_passthrough_logging_payload(
httpx_response=_make_response(SHORT_AUDIO_URL),
response_body={"RecognitionStatus": "Success"},
request_body={},
logging_obj=_make_logging_obj(),
url_route=SHORT_AUDIO_URL,
result=TRANSCRIPT,
start_time=datetime.now(),
end_time=datetime.now(),
cache_hit=False,
custom_llm_provider=None,
)
assert normalized["kwargs"].get("model") != "azure_speech/short-audio"
assert "response_cost" not in normalized["kwargs"]
class TestNormalizeDispatch:
def test_normalize_routes_to_azure_speech_handler(self):
normalized = PassThroughEndpointLogging().normalize_llm_passthrough_logging_payload(
httpx_response=_make_response(SHORT_AUDIO_URL),
response_body={"RecognitionStatus": "Success"},
request_body={},
logging_obj=_make_logging_obj(),
url_route=SHORT_AUDIO_URL,
result=TRANSCRIPT,
start_time=datetime.now(),
end_time=datetime.now(),
cache_hit=False,
custom_llm_provider="azure_speech",
)
assert normalized["standard_logging_response_object"] == {"response": TRANSCRIPT}
assert normalized["kwargs"]["model"] == "azure_speech/short-audio"
assert normalized["kwargs"]["custom_llm_provider"] == "azure_speech"
assert normalized["kwargs"]["response_cost"] == 0.0

View file

@ -2,6 +2,7 @@ import asyncio
import base64
import contextlib
import json
import logging
import os
import traceback
from collections.abc import Iterator, Mapping
@ -6136,3 +6137,296 @@ class TestAzureRelayDeploymentSegment:
)
assert [call["model"] for call in captured] == ["gpt", "gpt"]
AZURE_SPEECH_SHORT_AUDIO_ENDPOINT: Final = "/speech/recognition/conversation/cognitiveservices/v1"
AZURE_SPEECH_BATCH_ENDPOINT: Final = "/speechtotext/v3.2/transcriptions"
AZURE_SPEECH_PCM16_HEADER: Final = (
b"RIFF\x24\x0c\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00\x80\x3e\x00\x00\x00\x7d\x00\x00\x02\x00\x10\x00data\x00\x0c\x00\x00"
)
AZURE_SPEECH_WAV_BYTES: Final = AZURE_SPEECH_PCM16_HEADER + b"\x00" * 3072
AZURE_SPEECH_NON_UTF8_WAV_BYTES: Final = AZURE_SPEECH_PCM16_HEADER + bytes(range(256)) * 12
AZURE_SPEECH_TRANSCRIPT: Final = {"RecognitionStatus": "Success", "DisplayText": "The eagle has landed."}
@pytest.fixture
def azure_speech_client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
from litellm.proxy.proxy_server import app
monkeypatch.setenv("AZURE_SPEECH_API_KEY", "server-subscription-key")
monkeypatch.setenv("AZURE_SPEECH_REGION", "eastus")
monkeypatch.delenv("AZURE_SPEECH_API_BASE", 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"))
yield TestClient(app)
class TestAzureSpeechProxyRoute:
"""Drives the real FastAPI route with respx standing in for the Azure hosts only."""
def test_short_audio_forwards_raw_wav_bytes_with_server_key(self, azure_speech_client: TestClient) -> None:
with respx.mock(assert_all_called=True) as upstream:
route = upstream.post(
f"https://eastus.stt.speech.microsoft.com{AZURE_SPEECH_SHORT_AUDIO_ENDPOINT}"
).mock(return_value=httpx.Response(200, json=AZURE_SPEECH_TRANSCRIPT))
response = azure_speech_client.post(
f"/azure_speech{AZURE_SPEECH_SHORT_AUDIO_ENDPOINT}",
params={"language": "en-US", "format": "detailed"},
content=AZURE_SPEECH_WAV_BYTES,
headers={
"Content-Type": "audio/wav; codecs=audio/pcm; samplerate=16000",
"Authorization": "Bearer sk-virtual",
"Ocp-Apim-Subscription-Key": "caller-supplied-key",
"x-pass-ocp-apim-subscription-key": "caller-supplied-key",
},
)
assert (response.status_code, response.json()) == (200, AZURE_SPEECH_TRANSCRIPT)
sent = route.calls.last.request
assert sent.content == AZURE_SPEECH_WAV_BYTES
assert dict(sent.url.params) == {"language": "en-US", "format": "detailed"}
assert sent.headers["ocp-apim-subscription-key"] == "server-subscription-key"
assert sent.headers["content-type"] == "audio/wav; codecs=audio/pcm; samplerate=16000"
assert "authorization" not in sent.headers
assert "caller-supplied-key" not in repr(sent.headers)
def test_batch_json_goes_to_the_cognitive_services_host(self, azure_speech_client: TestClient) -> None:
body: Final = {"contentUrls": ["https://example.com/a.wav"], "locale": "en-US", "displayName": "job"}
with respx.mock(assert_all_called=True) as upstream:
route = upstream.post(f"https://eastus.api.cognitive.microsoft.com{AZURE_SPEECH_BATCH_ENDPOINT}").mock(
return_value=httpx.Response(201, json={"self": "https://eastus.api.cognitive.microsoft.com/x"})
)
response = azure_speech_client.post(
f"/azure_speech{AZURE_SPEECH_BATCH_ENDPOINT}",
json=body,
headers={"Authorization": "Bearer sk-virtual"},
)
assert response.status_code == 201
sent = route.calls.last.request
assert json.loads(sent.content) == body
assert sent.headers["ocp-apim-subscription-key"] == "server-subscription-key"
assert "authorization" not in sent.headers
def test_batch_multipart_upload_is_forwarded_byte_for_byte(self, azure_speech_client: TestClient) -> None:
with respx.mock(assert_all_called=True) as upstream:
route = upstream.post(f"https://eastus.api.cognitive.microsoft.com{AZURE_SPEECH_BATCH_ENDPOINT}").mock(
return_value=httpx.Response(201, json={"status": "NotStarted"})
)
response = azure_speech_client.post(
f"/azure_speech{AZURE_SPEECH_BATCH_ENDPOINT}",
files={"audio": ("eagle.wav", AZURE_SPEECH_NON_UTF8_WAV_BYTES, "audio/wav")},
data={"definition": json.dumps({"locales": ["en-US"]})},
headers={"Authorization": "Bearer sk-virtual"},
)
assert response.status_code == 201
sent = route.calls.last.request
assert sent.headers["content-type"].startswith("multipart/form-data; boundary=")
assert AZURE_SPEECH_NON_UTF8_WAV_BYTES in sent.content
assert b'name="definition"' in sent.content
assert sent.headers["ocp-apim-subscription-key"] == "server-subscription-key"
assert "authorization" not in sent.headers
def test_batch_get_is_forwarded_with_the_job_id_path(self, azure_speech_client: TestClient) -> None:
job_path: Final = f"{AZURE_SPEECH_BATCH_ENDPOINT}/8a5d3f2c-0b1e-4c7d-9e6f-1234567890ab/files"
with respx.mock(assert_all_called=True) as upstream:
route = upstream.get(f"https://eastus.api.cognitive.microsoft.com{job_path}").mock(
return_value=httpx.Response(200, json={"values": []})
)
response = azure_speech_client.get(f"/azure_speech{job_path}", headers={"Authorization": "Bearer sk-virtual"})
assert (response.status_code, response.json()) == (200, {"values": []})
assert route.calls.last.request.headers["ocp-apim-subscription-key"] == "server-subscription-key"
@pytest.mark.parametrize("method", ["GET", "POST"])
def test_batch_requests_are_logged_as_azure_speech_not_assemblyai(
self, azure_speech_client: TestClient, monkeypatch: pytest.MonkeyPatch, method: str
) -> None:
from litellm.integrations.custom_logger import CustomLogger
class _Recorder(CustomLogger):
def __init__(self) -> None:
super().__init__()
self.payloads: list[dict[str, object]] = [] # mutable-ok: test recorder accumulates callback payloads
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time) -> None:
self.payloads.append(kwargs["standard_logging_object"])
recorder: Final = _Recorder()
monkeypatch.setattr(litellm, "_async_success_callback", [*litellm._async_success_callback, recorder])
with respx.mock(assert_all_called=True) as upstream:
upstream.request(method, f"https://eastus.api.cognitive.microsoft.com{AZURE_SPEECH_BATCH_ENDPOINT}").mock(
return_value=httpx.Response(200, json={"values": []})
)
response = azure_speech_client.request(
method,
f"/azure_speech{AZURE_SPEECH_BATCH_ENDPOINT}",
json={"locale": "en-US"} if method == "POST" else None,
headers={"Authorization": "Bearer sk-virtual"},
)
assert response.status_code == 200
assert [(p["model"], p["custom_llm_provider"], p["response_cost"]) for p in recorder.payloads] == [
("azure_speech/batch-transcription", "azure_speech", 0.0)
]
def test_api_base_wins_over_region_for_both_families(
self, azure_speech_client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("AZURE_SPEECH_API_BASE", "https://my-speech.cognitiveservices.azure.com")
with respx.mock(assert_all_called=True) as upstream:
short_audio = upstream.post(
f"https://my-speech.cognitiveservices.azure.com{AZURE_SPEECH_SHORT_AUDIO_ENDPOINT}"
).mock(return_value=httpx.Response(200, json=AZURE_SPEECH_TRANSCRIPT))
batch = upstream.get(f"https://my-speech.cognitiveservices.azure.com{AZURE_SPEECH_BATCH_ENDPOINT}").mock(
return_value=httpx.Response(200, json={"values": []})
)
azure_speech_client.post(
f"/azure_speech{AZURE_SPEECH_SHORT_AUDIO_ENDPOINT}",
content=AZURE_SPEECH_WAV_BYTES,
headers={"Content-Type": "audio/wav", "Authorization": "Bearer sk-virtual"},
)
azure_speech_client.get(f"/azure_speech{AZURE_SPEECH_BATCH_ENDPOINT}", headers={"Authorization": "Bearer x"})
assert short_audio.called and batch.called
@pytest.mark.parametrize("endpoint", ["openai/deployments/whisper/audio/transcriptions", "speech", "speechtotext"])
def test_unknown_path_family_is_rejected_before_any_upstream_call(
self, azure_speech_client: TestClient, endpoint: str
) -> None:
with respx.mock(assert_all_called=False) as upstream:
catch_all = upstream.route().mock(return_value=httpx.Response(200))
response = azure_speech_client.post(
f"/azure_speech/{endpoint}", content=b"x", headers={"Authorization": "Bearer sk-virtual"}
)
assert response.status_code == 400
assert not catch_all.called
def test_missing_region_and_base_is_rejected_before_any_upstream_call(
self, azure_speech_client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("AZURE_SPEECH_REGION")
with respx.mock(assert_all_called=False) as upstream:
catch_all = upstream.route().mock(return_value=httpx.Response(200))
response = azure_speech_client.post(
f"/azure_speech{AZURE_SPEECH_SHORT_AUDIO_ENDPOINT}",
content=AZURE_SPEECH_WAV_BYTES,
headers={"Content-Type": "audio/wav", "Authorization": "Bearer sk-virtual"},
)
assert response.status_code == 400
assert "AZURE_SPEECH_REGION" in response.text
assert not catch_all.called
def test_missing_api_key_is_rejected_before_any_upstream_call(
self, azure_speech_client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("AZURE_SPEECH_API_KEY")
with respx.mock(assert_all_called=False) as upstream:
catch_all = upstream.route().mock(return_value=httpx.Response(200))
response = azure_speech_client.post(
f"/azure_speech{AZURE_SPEECH_SHORT_AUDIO_ENDPOINT}",
content=AZURE_SPEECH_WAV_BYTES,
headers={"Content-Type": "audio/wav", "Authorization": "Bearer sk-virtual"},
)
assert response.status_code == 400
assert "AZURE_SPEECH_API_KEY" in response.text
assert not catch_all.called
def test_azure_speech_is_a_mapped_pass_through_route(self) -> None:
from litellm.proxy._types import LiteLLMRoutes
assert "/azure_speech" in LiteLLMRoutes.mapped_pass_through_routes.value
def _azure_speech_real_auth_attrs() -> dict[str, object]:
from litellm.caching.caching import DualCache
from litellm.proxy.utils import ProxyLogging
user_api_key_cache: Final = DualCache()
return {
"prisma_client": None,
"user_api_key_cache": user_api_key_cache,
"proxy_logging_obj": ProxyLogging(user_api_key_cache=user_api_key_cache),
"master_key": "sk-master-key",
"general_settings": {},
"llm_model_list": [],
"llm_router": None,
"open_telemetry_logger": None,
"user_custom_auth": None,
"jwt_handler": None,
}
class TestAzureSpeechRawBodyThroughRealAuth:
"""user_api_key_auth reads the body before the route runs; raw audio must not be parsed as JSON."""
def _post_wav(
self, monkeypatch: pytest.MonkeyPatch, path: str, api_key: str, body: bytes = AZURE_SPEECH_WAV_BYTES
) -> httpx.Response:
from litellm.proxy.proxy_server import app
monkeypatch.setenv("AZURE_SPEECH_API_KEY", "server-subscription-key")
monkeypatch.setenv("AZURE_SPEECH_REGION", "eastus")
monkeypatch.delenv("AZURE_SPEECH_API_BASE", raising=False)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
litellm.in_memory_llm_clients_cache.flush_cache()
with patch.multiple( # test-quality-ok: the real user_api_key_auth reads proxy_server module globals (master_key, caches) that have no injection seam
"litellm.proxy.proxy_server", **_azure_speech_real_auth_attrs()
):
client = TestClient(app)
return client.post(
path,
params={"language": "en-US"},
content=body,
headers={"Content-Type": "audio/wav", "Authorization": f"Bearer {api_key}"},
)
@pytest.mark.parametrize("body", [AZURE_SPEECH_WAV_BYTES, AZURE_SPEECH_NON_UTF8_WAV_BYTES], ids=["ascii", "binary"])
def test_master_key_with_raw_wav_body_reaches_azure_without_a_parse_attempt(
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, body: bytes
) -> None:
with respx.mock(assert_all_called=True) as upstream, caplog.at_level(logging.ERROR, logger="LiteLLM Proxy"):
route = upstream.post(
f"https://eastus.stt.speech.microsoft.com{AZURE_SPEECH_SHORT_AUDIO_ENDPOINT}"
).mock(return_value=httpx.Response(200, json=AZURE_SPEECH_TRANSCRIPT))
response = self._post_wav(
monkeypatch, f"/azure_speech{AZURE_SPEECH_SHORT_AUDIO_ENDPOINT}", "sk-master-key", body=body
)
assert (response.status_code, response.json()) == (200, AZURE_SPEECH_TRANSCRIPT)
assert route.calls.last.request.content == body
assert [record.message for record in caplog.records if "request body" in record.message] == []
def test_wrong_litellm_key_with_raw_wav_body_is_rejected(self, monkeypatch: pytest.MonkeyPatch) -> None:
with respx.mock(assert_all_called=False) as upstream:
catch_all = upstream.route().mock(return_value=httpx.Response(200))
response = self._post_wav(monkeypatch, f"/azure_speech{AZURE_SPEECH_SHORT_AUDIO_ENDPOINT}", "sk-wrong")
assert response.status_code in (400, 401), response.text
assert not catch_all.called
@pytest.mark.parametrize("path", ["/v1/chat/completions", f"/azure_speech{AZURE_SPEECH_BATCH_ENDPOINT}"])
def test_audio_content_type_off_the_short_audio_route_is_still_parsed_as_json(
self, monkeypatch: pytest.MonkeyPatch, path: str
) -> None:
response = self._post_wav(monkeypatch, path, "sk-master-key", body=b'{}{"model": "gpt-4o"}')
assert response.status_code == 400
assert "Invalid JSON payload" in response.text

View file

@ -159,6 +159,22 @@ def test_assemblyai_region_matching():
assert passthrough_router.get_credentials(custom_llm_provider="assemblyai", region_name=None) == "sk-us"
def test_azure_speech_dashboard_credential_resolves_through_flagged_deployment(monkeypatch):
monkeypatch.delenv("AZURE_SPEECH_API_KEY", raising=False)
CredentialAccessor.upsert_credentials([_credential("azure-speech-prod", "azure-subscription-key")])
llm_router = litellm.Router(
model_list=[
_flagged_deployment("azure_speech/short-audio", litellm_credential_name="azure-speech-prod"),
]
)
passthrough_router = _passthrough_router(llm_router)
assert (
passthrough_router.get_credentials(custom_llm_provider="azure_speech", region_name=None)
== "azure-subscription-key"
)
def test_env_fallback_when_no_router(monkeypatch):
passthrough_router = _passthrough_router(None)
monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env")

View file

@ -80,6 +80,7 @@ export enum Providers {
SageMaker = "AWS SageMaker",
Azure = "Azure",
Azure_AI_Studio = "Azure AI Foundry (Studio)",
Azure_Speech = "Azure AI Speech",
AZURE_TEXT = "Azure Text",
BASETEN = "Baseten",
BYTEZ = "Bytez",
@ -193,6 +194,7 @@ export const provider_map: Record<string, string> = {
AUTO_ROUTER: "auto_router",
Azure: "azure",
Azure_AI_Studio: "azure_ai",
Azure_Speech: "azure_speech",
AZURE_TEXT: "azure_text",
BASETEN: "baseten",
Bedrock: "bedrock",
@ -310,6 +312,7 @@ export const providerLogoMap: Partial<Record<Providers, string>> = {
[Providers.AssemblyAI]: assemblyaiSmallLogo.src,
[Providers.Azure]: microsoftAzureLogo.src,
[Providers.Azure_AI_Studio]: microsoftAzureLogo.src,
[Providers.Azure_Speech]: microsoftAzureLogo.src,
[Providers.AZURE_TEXT]: microsoftAzureLogo.src,
[Providers.BASETEN]: basetenLogo.src,
[Providers.Bedrock]: bedrockLogo.src,
@ -427,6 +430,7 @@ const providerPlaceholderMap: Partial<Record<Providers, string>> = {
[Providers.Anthropic]: "claude-3-opus",
[Providers.Azure]: "my-deployment",
[Providers.Azure_AI_Studio]: "azure_ai/command-r-plus",
[Providers.Azure_Speech]: "azure_speech/short-audio",
[Providers.Bedrock]: "claude-3-opus",
[Providers.CHATGPT]: "chatgpt/gpt-5.4",
[Providers.Cognition]: "cognition/swe-1.7",

View file

@ -1612,6 +1612,82 @@ export interface paths {
patch: operations["azure_proxy_route_azure_ai__endpoint__patch"];
trace?: never;
};
"/azure_speech/{endpoint}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Azure Speech Proxy Route
* @description Pass-through for the Azure AI Speech REST APIs (speech to text), e.g.
* `POST /azure_speech/speech/recognition/conversation/cognitiveservices/v1?language=en-US`
* with the raw audio as the body, or `POST /azure_speech/speechtotext/v3.2/transcriptions`.
*
* The body is forwarded byte for byte and the proxy injects its own
* `Ocp-Apim-Subscription-Key`; the caller's `Authorization` header is the LiteLLM key
* and is never forwarded.
*
* [Docs](https://docs.litellm.ai/docs/pass_through/azure_speech)
*/
get: operations["azure_speech_proxy_route_azure_speech__endpoint__get"];
/**
* Azure Speech Proxy Route
* @description Pass-through for the Azure AI Speech REST APIs (speech to text), e.g.
* `POST /azure_speech/speech/recognition/conversation/cognitiveservices/v1?language=en-US`
* with the raw audio as the body, or `POST /azure_speech/speechtotext/v3.2/transcriptions`.
*
* The body is forwarded byte for byte and the proxy injects its own
* `Ocp-Apim-Subscription-Key`; the caller's `Authorization` header is the LiteLLM key
* and is never forwarded.
*
* [Docs](https://docs.litellm.ai/docs/pass_through/azure_speech)
*/
put: operations["azure_speech_proxy_route_azure_speech__endpoint__put"];
/**
* Azure Speech Proxy Route
* @description Pass-through for the Azure AI Speech REST APIs (speech to text), e.g.
* `POST /azure_speech/speech/recognition/conversation/cognitiveservices/v1?language=en-US`
* with the raw audio as the body, or `POST /azure_speech/speechtotext/v3.2/transcriptions`.
*
* The body is forwarded byte for byte and the proxy injects its own
* `Ocp-Apim-Subscription-Key`; the caller's `Authorization` header is the LiteLLM key
* and is never forwarded.
*
* [Docs](https://docs.litellm.ai/docs/pass_through/azure_speech)
*/
post: operations["azure_speech_proxy_route_azure_speech__endpoint__post"];
/**
* Azure Speech Proxy Route
* @description Pass-through for the Azure AI Speech REST APIs (speech to text), e.g.
* `POST /azure_speech/speech/recognition/conversation/cognitiveservices/v1?language=en-US`
* with the raw audio as the body, or `POST /azure_speech/speechtotext/v3.2/transcriptions`.
*
* The body is forwarded byte for byte and the proxy injects its own
* `Ocp-Apim-Subscription-Key`; the caller's `Authorization` header is the LiteLLM key
* and is never forwarded.
*
* [Docs](https://docs.litellm.ai/docs/pass_through/azure_speech)
*/
delete: operations["azure_speech_proxy_route_azure_speech__endpoint__delete"];
options?: never;
head?: never;
/**
* Azure Speech Proxy Route
* @description Pass-through for the Azure AI Speech REST APIs (speech to text), e.g.
* `POST /azure_speech/speech/recognition/conversation/cognitiveservices/v1?language=en-US`
* with the raw audio as the body, or `POST /azure_speech/speechtotext/v3.2/transcriptions`.
*
* The body is forwarded byte for byte and the proxy injects its own
* `Ocp-Apim-Subscription-Key`; the caller's `Authorization` header is the LiteLLM key
* and is never forwarded.
*
* [Docs](https://docs.litellm.ai/docs/pass_through/azure_speech)
*/
patch: operations["azure_speech_proxy_route_azure_speech__endpoint__patch"];
trace?: never;
};
"/batches": {
parameters: {
query?: never;
@ -43394,6 +43470,161 @@ export interface operations {
};
};
};
azure_speech_proxy_route_azure_speech__endpoint__get: {
parameters: {
query?: never;
header?: never;
path: {
endpoint: 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"];
};
};
};
};
azure_speech_proxy_route_azure_speech__endpoint__put: {
parameters: {
query?: never;
header?: never;
path: {
endpoint: 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"];
};
};
};
};
azure_speech_proxy_route_azure_speech__endpoint__post: {
parameters: {
query?: never;
header?: never;
path: {
endpoint: 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"];
};
};
};
};
azure_speech_proxy_route_azure_speech__endpoint__delete: {
parameters: {
query?: never;
header?: never;
path: {
endpoint: 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"];
};
};
};
};
azure_speech_proxy_route_azure_speech__endpoint__patch: {
parameters: {
query?: never;
header?: never;
path: {
endpoint: 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"];
};
};
};
};
list_batches_batches_get: {
parameters: {
query?: {