fix(transcription): honor base_url alias for Groq Whisper and report it as the api base (#43917)

* fix(transcription): honor base_url alias for Groq Whisper and report it as the api base

transcription() and speech() only accepted api_base, so a deployment configured with base_url leaked the alias into the provider params, which Groq rejected as an unknown param, and the request never reached the internal gateway. get_api_base() now reads the same alias so response headers and logs show the configured endpoint instead of the provider default

Resolves LIT-9071

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(speech): keep base_url after existing audio params, route Vertex speech to it, skip empty alias

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-30 15:03:23 -07:00 • committed by GitHub
parent e662772ad1
commit b41715b0c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 112 additions and 6 deletions

View file

@ -59,6 +59,10 @@ def get_api_base(model: str, optional_params: dict | LiteLLM_Params) -> str | No
if _optional_params.api_base is not None:
return _optional_params.api_base
extra_params: Final = _optional_params.model_extra
base_url_alias: Final = extra_params.get("base_url") if extra_params is not None else None
if isinstance(base_url_alias, str) and base_url_alias:
return base_url_alias
if litellm.model_alias_map and model in litellm.model_alias_map:
model = litellm.model_alias_map[model]

View file

@ -7931,6 +7931,7 @@ def transcription(
api_version: str | None = None,
max_retries: int | None = None,
custom_llm_provider=None,
base_url: str | None = None,
**kwargs,
) -> TranscriptionResponse | Coroutine[object, object, TranscriptionResponse]:
"""
@ -7964,7 +7965,7 @@ def transcription(
model, custom_llm_provider, dynamic_api_key, api_base = get_llm_provider(
model=model,
custom_llm_provider=custom_llm_provider,
api_base=api_base,
api_base=api_base or base_url,
api_key=api_key,
)
@ -8237,6 +8238,7 @@ def speech(
headers: dict | None = None,
custom_llm_provider: str | None = None,
aspeech: bool | None = None,
base_url: str | None = None,
**kwargs,
) -> HttpxBinaryResponseContent | Coroutine[object, object, HttpxBinaryResponseContent]:
user: Final = kwargs.get("user", None)
@ -8246,7 +8248,7 @@ def speech(
model_info: Final = kwargs.get("model_info", None)
shared_session: Final = kwargs.get("shared_session", None)
model, custom_llm_provider, dynamic_api_key, api_base = get_llm_provider(
model=model, custom_llm_provider=custom_llm_provider, api_base=api_base
model=model, custom_llm_provider=custom_llm_provider, api_base=api_base or base_url
)
kwargs.pop("tags", [])
@ -8550,7 +8552,7 @@ def speech(
extra_headers=headers,
base_llm_http_handler=base_llm_http_handler,
aspeech=aspeech or False,
api_base=generic_optional_params.api_base,
api_base=api_base,
api_key=None, # Vertex AI uses OAuth, not API key
**kwargs,
)

View file

@ -91,3 +91,22 @@ def test_providers_with_a_fixed_base_still_get_it(model, expected, monkeypatch):
monkeypatch.delenv(env, raising=False)
assert litellm.get_api_base(model=model, optional_params={}) == expected
def test_base_url_alias_is_reported_as_the_api_base():
api_base = litellm.get_api_base(
model="groq/whisper-large-v3", optional_params={"base_url": "https://groq.gateway.internal/openai/v1"}
)
assert api_base == "https://groq.gateway.internal/openai/v1"
assert (
litellm.get_api_base(
model="groq/whisper-large-v3",
optional_params={"api_base": "https://explicit.internal/v1", "base_url": "https://alias.internal/v1"},
)
== "https://explicit.internal/v1"
)
assert (
litellm.get_api_base(model="groq/whisper-large-v3", optional_params={"base_url": ""})
== "https://api.groq.com/openai/v1"
)

View file

@ -261,9 +261,7 @@ class TestVertexAILyriaTextToSpeechConfig:
)
def test_get_complete_url_encodes_injected_predict_path_segments(self, monkeypatch: pytest.MonkeyPatch) -> None:
injected: Final = (
"victim-project/locations/us-central1/publishers/google/models/other-model:predict?ignored="
)
injected: Final = "victim-project/locations/us-central1/publishers/google/models/other-model:predict?ignored="
encoded: Final = (
"victim-project%2Flocations%2Fus-central1%2Fpublishers%2Fgoogle"
"%2Fmodels%2Fother-model%3Apredict%3Fignored%3D"
@ -554,6 +552,33 @@ class TestVertexAILyriaTextToSpeechConfig:
assert mock_post.call_args.kwargs["json"] == expected_body
@pytest.mark.parametrize("endpoint_kwarg", ["api_base", "base_url"])
def test_litellm_speech_vertex_ai_sends_request_to_the_configured_endpoint(endpoint_kwarg: str):
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
mock_response.json.return_value = {"audioContent": "SGVsbG8gV29ybGQ="}
with (
patch.object( # test-quality-ok: litellm.speech has no seam for Vertex token minting
VertexAITextToSpeechConfig, "_ensure_access_token", return_value=("mock-token", "test-project")
),
patch( # test-quality-ok: litellm.speech has no seam for the HTTP handler
"litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post", return_value=mock_response
) as mock_post,
):
response = litellm.speech(
model="vertex_ai/chirp",
input="Hello",
voice="en-US-Chirp3-HD-Charon",
vertex_project="test-project",
vertex_location="us-central1",
**{endpoint_kwarg: "https://tts.gateway.internal/v1/text:synthesize"},
)
assert mock_post.call_args.kwargs["url"] == "https://tts.gateway.internal/v1/text:synthesize"
assert response.content == b"Hello World"
@patch("litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post")
@patch.object(VertexAITextToSpeechConfig, "_ensure_access_token")
@patch.object(VertexAITextToSpeechConfig, "_get_token_and_url")

View file

@ -4188,6 +4188,62 @@ def test_azure_ai_speech_on_a_foundry_host_uses_the_azure_openai_deployment_rout
assert response.content == b"mp3-bytes"
GROQ_INTERNAL_BASE: Final = "https://groq.gateway.internal/openai/v1"
GROQ_WAV_FILE: Final = ("tone.wav", b"RIFF\x00\x00\x00\x00WAVE", "audio/wav")
def test_groq_transcription_honors_base_url_alias(respx_mock: respx.MockRouter):
route: Final = respx_mock.post(f"{GROQ_INTERNAL_BASE}/audio/transcriptions").mock(
return_value=httpx.Response(200, json={"text": "hello"})
)
response: Final = litellm.transcription(
model="groq/whisper-large-v3",
file=GROQ_WAV_FILE,
base_url=GROQ_INTERNAL_BASE,
api_key="fake-key",
)
assert route.called
assert response.text == "hello"
async def test_groq_atranscription_honors_base_url_alias(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
route: Final = respx_mock.post(f"{GROQ_INTERNAL_BASE}/audio/transcriptions").mock(
return_value=httpx.Response(200, json={"text": "hello"})
)
response: Final = await litellm.atranscription(
model="groq/whisper-large-v3",
file=GROQ_WAV_FILE,
base_url=GROQ_INTERNAL_BASE,
api_key="fake-key",
)
assert route.called
assert response.text == "hello"
def test_groq_speech_honors_base_url_alias(respx_mock: respx.MockRouter):
route: Final = respx_mock.post(f"{GROQ_INTERNAL_BASE}/audio/speech").mock(
return_value=httpx.Response(200, content=b"mp3-bytes")
)
response: Final = litellm.speech(
model="groq/playai-tts",
input="hello",
voice="Fritz-PlayAI",
base_url=GROQ_INTERNAL_BASE,
api_key="fake-key",
)
assert route.called
assert response.content == b"mp3-bytes"
FORWARDED_CLIENT_HEADERS: Final = {"x-forwarded-for": "10.0.0.1", "x-amzn-trace-id": "Root=1-lit7694"}