Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions litellm/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -1043,8 +1043,6 @@
"sail",
]

OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS: Final = frozenset({"openai"} | frozenset(openai_compatible_providers))

openai_text_completion_compatible_providers: Final[list] = [ # providers that support `/v1/completions`
"together_ai",
"fireworks_ai",
Expand Down
38 changes: 37 additions & 1 deletion litellm/llms/openai_like/json_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@
"""

import json
from collections.abc import Mapping, Sequence
from pathlib import Path
from types import MappingProxyType
from typing import Final

from litellm._logging import verbose_logger
from litellm.constants import openai_compatible_providers

OPENAI_AUDIO_ENDPOINTS: Final = frozenset({"/v1/audio/transcriptions", "/v1/audio/speech"})


class SimpleProviderConfig:
Expand All @@ -21,7 +26,7 @@ def __init__(self, slug: str, data: dict):
self.param_mappings = data.get("param_mappings", {})
self.constraints = data.get("constraints", {})
self.special_handling = data.get("special_handling", {})
self.supported_endpoints = data.get("supported_endpoints", [])
self.supported_endpoints: Final[Sequence[str]] = data.get("supported_endpoints", [])


class JSONProviderRegistry:
Expand Down Expand Up @@ -78,6 +83,11 @@ def supports_responses_api(cls, slug: str) -> bool:
return False
return "/v1/responses" in provider.supported_endpoints

@classmethod
def declared_endpoints(cls) -> Mapping[str, Sequence[str]]:
"""Endpoints each JSON provider declares for itself"""
return MappingProxyType({slug: provider.supported_endpoints for slug, provider in cls._providers.items()})

@classmethod
def list_providers(cls) -> list:
"""List all registered provider slugs"""
Expand All @@ -86,3 +96,29 @@ def list_providers(cls) -> list:

# Load on import
JSONProviderRegistry.load()


def derive_openai_audio_transcription_providers(
compatible_providers: Sequence[str],
declared_endpoints: Mapping[str, Sequence[str]],
) -> frozenset[str]:
"""Providers allowed to drive the OpenAI `/v1/audio/*` transport.

A JSON provider joins only when it declares an audio endpoint for itself, so a chat-only
provider can never be sent the caller's OpenAI credentials against its third-party base url.
"""
non_json_providers: Final = frozenset(
provider for provider in compatible_providers if provider not in declared_endpoints
)
declaring_providers: Final = frozenset(
slug
for slug, endpoints in declared_endpoints.items()
if any(endpoint in OPENAI_AUDIO_ENDPOINTS for endpoint in endpoints)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Either declaration enables both operations
If a JSON provider declares only /v1/audio/transcriptions, this set also permits speech() requests to that provider. Declaring only /v1/audio/speech likewise permits transcription() requests. Both functions use this set, so either declaration sends the other, undeclared operation upstream rather than rejecting it.

Knowledge Base Used: Provider adapters and capabilities

)
return frozenset({"openai"}) | non_json_providers | declaring_providers


OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS: Final = derive_openai_audio_transcription_providers(
compatible_providers=openai_compatible_providers,
declared_endpoints=JSONProviderRegistry.declared_endpoints(),
)
8 changes: 5 additions & 3 deletions litellm/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@
DEFAULT_MOCK_RESPONSE_COMPLETION_TOKEN_COUNT,
DEFAULT_MOCK_RESPONSE_PROMPT_TOKEN_COUNT,
NADIR_DEFAULT_API_BASE,
OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS,
)
from litellm.exceptions import LiteLLMUnknownProvider
from litellm.integrations.custom_logger import CustomLogger
Expand Down Expand Up @@ -125,7 +124,10 @@
from litellm.llms.cohere.common_utils import CohereModelInfo
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler, http2_enabled
from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
from litellm.llms.openai_like.json_loader import (
OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS,
JSONProviderRegistry,
)
from litellm.llms.vertex_ai.common_utils import (
VertexAIModelRoute,
get_vertex_ai_model_route,
Expand Down Expand Up @@ -8343,7 +8345,7 @@ def speech(
_is_async=aspeech or False,
)
elif custom_llm_provider == "openai" or (
custom_llm_provider in litellm.openai_compatible_providers
custom_llm_provider in OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Speech ignores provider credentials
When a JSON provider declares audio speech, get_llm_provider() resolves its provider-specific key into dynamic_api_key, but this route does not use that value. Even if the provider key is configured, speech() can instead fall back to OPENAI_API_KEY and send it to the provider's base URL. How this was verified: JSON provider resolution returns its configured key separately, while speech dispatch ignores that value and falls back to the OpenAI key.

Knowledge Base Used: Provider adapters and capabilities

and custom_llm_provider not in AZURE_OPENAI_AUDIO_PROVIDERS
):
if voice is None or not (isinstance(voice, str)):
Expand Down
128 changes: 128 additions & 0 deletions tests/unit/llms/openai_like/test_json_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""
A JSON-configured provider may use the OpenAI `/v1/audio/*` transport only when it declares
that endpoint for itself, so `transcription()` and `speech()` cannot carry `OPENAI_API_KEY`
to a provider whose entry in `providers.json` advertises chat only.
"""

import io
from collections.abc import Sequence
from typing import Final

import pytest
import respx

import litellm
from litellm.constants import openai_compatible_providers
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
from litellm.llms.openai_like.json_loader import (
OPENAI_AUDIO_ENDPOINTS,
OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS,
JSONProviderRegistry,
derive_openai_audio_transcription_providers,
)

OPENAI_KEY_CANARY: Final = "sk-leaked-openai-key"


def _declares_audio(endpoints: Sequence[str]) -> bool:
return any(endpoint in OPENAI_AUDIO_ENDPOINTS for endpoint in endpoints)


def _chat_only_json_provider() -> str:
"""A JSON provider that is reachable as OpenAI-compatible yet declares no audio endpoint"""
return next(
slug
for slug, endpoints in JSONProviderRegistry.declared_endpoints().items()
if slug in openai_compatible_providers and not _declares_audio(endpoints)
)


def _audio_file() -> io.BytesIO:
file: Final = io.BytesIO(b"riff-bytes")
file.name = "audio.wav"
return file


def test_json_provider_is_audio_capable_only_when_it_declares_audio():
for slug, endpoints in JSONProviderRegistry.declared_endpoints().items():
assert (slug in OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS) is _declares_audio(endpoints), slug


def test_python_openai_compatible_providers_keep_the_audio_transport():
for provider in openai_compatible_providers:
if not JSONProviderRegistry.exists(provider):
assert provider in OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS, provider


@pytest.mark.parametrize(
["declared", "expected"],
[
(["/v1/audio/transcriptions"], True),
(["/v1/audio/speech"], True),
(["/v1/chat/completions", "/v1/audio/speech"], True),
(["/v1/chat/completions", "/v1/responses"], False),
([], False),
],
ids=["transcriptions", "speech", "audio_plus_chat", "chat_only", "nothing_declared"],
)
def test_derivation_grants_membership_on_declared_audio_endpoints_only(declared: list[str], expected: bool):
derived: Final = derive_openai_audio_transcription_providers(
compatible_providers=["groq", "chat-only"],
declared_endpoints={"chat-only": ["/v1/chat/completions"], "voiced": declared},
)

assert ("voiced" in derived) is expected
assert "groq" in derived
assert "chat-only" not in derived
assert "openai" in derived


def test_transcription_sends_no_openai_key_to_json_provider_without_audio_endpoint(monkeypatch):
slug: Final = _chat_only_json_provider()
monkeypatch.delenv(JSONProviderRegistry.get(slug).api_key_env, raising=False)
monkeypatch.setenv("OPENAI_API_KEY", OPENAI_KEY_CANARY)

with respx.mock(assert_all_called=False) as upstream:
route: Final = upstream.post(f"{JSONProviderRegistry.get(slug).base_url}/audio/transcriptions").respond(
200, json={"text": "should never be reached"}
)

with pytest.raises(ValueError, match="Unmapped provider"):
litellm.transcription(model=f"{slug}/whisper-1", file=_audio_file())

assert route.called is False
assert len(upstream.calls) == 0


def test_speech_sends_no_openai_key_to_json_provider_without_audio_endpoint(monkeypatch):
slug: Final = _chat_only_json_provider()
monkeypatch.delenv(JSONProviderRegistry.get(slug).api_key_env, raising=False)
monkeypatch.setenv("OPENAI_API_KEY", OPENAI_KEY_CANARY)

with respx.mock(assert_all_called=False) as upstream:
route: Final = upstream.post(f"{JSONProviderRegistry.get(slug).base_url}/audio/speech").respond(
200, content=b"should never be reached"
)

with pytest.raises(Exception, match=f"Unable to map the custom llm provider={slug}"):
litellm.speech(model=f"{slug}/tts-1", input="hello", voice="alloy")

assert route.called is False
assert len(upstream.calls) == 0


def test_transcription_still_routes_a_python_provider_with_its_own_credential(monkeypatch):
monkeypatch.setenv("GROQ_API_KEY", "gsk-provider-key")
_, _, _, api_base = get_llm_provider(
model="groq/whisper-large-v3-turbo", custom_llm_provider=None, api_base=None, api_key=None
)

with respx.mock() as upstream:
route: Final = upstream.post(f"{api_base}/audio/transcriptions").respond(
200, json={"text": "hello world", "language": "english"}
)

response: Final = litellm.transcription(model="groq/whisper-large-v3-turbo", file=_audio_file())

assert response.text == "hello world"
assert route.calls[0].request.headers["authorization"] == "Bearer gsk-provider-key"
Loading