Skip to content

Commit 0267b9d

Browse files
fix(whisper): rewind audio file and preserve real error in requests fallback
When litellm.transcription fails and wdoc falls back to a direct requests call, it reused the same file handle that litellm had already read to EOF, so the fallback uploaded an empty body. Endpoints answer an empty upload with a misleading status (e.g. 415 Unsupported Media Type) that has nothing to do with the actual failure, which is likely what produced the confusing 415 seen behind an auth proxy. Rewind the handle with seek(0) before the fallback POST. Also, the original litellm error was only logged as a warning and then lost when the fallback failed. Wrap the fallback request so its failure re-raises with BOTH the endpoint error and the original litellm error, so the true root cause (often an auth/proxy misconfiguration) survives. Adds two regression tests (call the undecorated function to bypass the joblib doc-loaders cache): one asserts the fallback uploads the full file, one asserts the raised error mentions both the endpoint and litellm errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c0a62c3 commit 0267b9d

2 files changed

Lines changed: 117 additions & 4 deletions

File tree

tests/test_wdoc.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,3 +746,98 @@ def _boom(**kwargs):
746746
# warn mode keeps the traceback attached exactly once (via exc_info), not
747747
# inlined into the message
748748
assert warn_rec["exception"] is not None
749+
750+
751+
@pytest.mark.basic
752+
def test_whisper_fallback_rewinds_file(tmp_path, monkeypatch):
753+
"""When litellm.transcription fails and wdoc falls back to a direct request,
754+
it must rewind the audio file first. litellm reads the handle to EOF, so
755+
without a seek(0) the fallback uploads an empty body and the endpoint answers
756+
with a misleading error (e.g. 415 Unsupported Media Type) unrelated to the
757+
real failure. Regression test: the fallback must upload the full file."""
758+
import litellm
759+
import requests
760+
761+
from wdoc.utils.loaders import shared_audio
762+
763+
monkeypatch.setenv("WDOC_WHISPER_API_KEY", "test-key")
764+
monkeypatch.setenv("WDOC_WHISPER_ENDPOINT", "https://fake.endpoint/")
765+
766+
audio_path = tmp_path / "sample.mp3"
767+
payload = b"FAKE_AUDIO_BYTES_0123456789"
768+
audio_path.write_bytes(payload)
769+
770+
def fake_transcription(**kwargs):
771+
# emulate litellm consuming the file to EOF before failing
772+
kwargs["file"].read()
773+
raise RuntimeError("litellm boom")
774+
775+
uploaded = {}
776+
777+
class FakeResponse:
778+
def raise_for_status(self):
779+
return None
780+
781+
def json(self):
782+
return {"text": "ok"}
783+
784+
def fake_post(url, files=None, data=None, headers=None):
785+
uploaded["content"] = files["file"].read()
786+
return FakeResponse()
787+
788+
monkeypatch.setattr(litellm, "transcription", fake_transcription)
789+
monkeypatch.setattr(requests, "post", fake_post)
790+
791+
# call the undecorated function to bypass the joblib disk cache, so the test
792+
# actually exercises the code and stays reproducible across runs
793+
out = shared_audio.transcribe_audio_whisper.func(
794+
audio_path=audio_path,
795+
audio_hash="rewind-hash",
796+
language=None,
797+
prompt=None,
798+
)
799+
800+
assert out == {"text": "ok"}
801+
# the crux: the fallback uploaded the whole file, not the empty EOF leftover
802+
assert uploaded["content"] == payload
803+
804+
805+
@pytest.mark.basic
806+
def test_whisper_fallback_surfaces_both_errors(tmp_path, monkeypatch):
807+
"""If the direct-request fallback also fails, the raised error must mention
808+
BOTH the endpoint error and the original litellm error, so the real root
809+
cause (often an auth/proxy misconfiguration) is not masked by a spurious
810+
status code."""
811+
import litellm
812+
import requests
813+
814+
from wdoc.utils.loaders import shared_audio
815+
816+
monkeypatch.setenv("WDOC_WHISPER_API_KEY", "test-key")
817+
monkeypatch.setenv("WDOC_WHISPER_ENDPOINT", "https://fake.endpoint/")
818+
819+
audio_path = tmp_path / "sample.mp3"
820+
audio_path.write_bytes(b"FAKE_AUDIO_BYTES_0123456789")
821+
822+
def fake_transcription(**kwargs):
823+
kwargs["file"].read()
824+
raise RuntimeError("original litellm auth failure")
825+
826+
def fake_post(url, files=None, data=None, headers=None):
827+
raise requests.exceptions.HTTPError("415 Unsupported Media Type")
828+
829+
monkeypatch.setattr(litellm, "transcription", fake_transcription)
830+
monkeypatch.setattr(requests, "post", fake_post)
831+
832+
# call the undecorated function to bypass the joblib disk cache
833+
with pytest.raises(Exception) as excinfo:
834+
shared_audio.transcribe_audio_whisper.func(
835+
audio_path=audio_path,
836+
audio_hash="both-errors-hash",
837+
language=None,
838+
prompt=None,
839+
)
840+
841+
msg = str(excinfo.value)
842+
assert "415 Unsupported Media Type" in msg
843+
assert "original litellm auth failure" in msg

wdoc/utils/loaders/shared_audio.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -290,14 +290,32 @@ def transcribe_audio_whisper(
290290
if whisper_api_key:
291291
headers["Authorization"] = f"Bearer {whisper_api_key}"
292292

293+
# Rewind the file: litellm.transcription above already read
294+
# `audio_file` to EOF, so without this seek the fallback would
295+
# upload an empty body and the endpoint would answer with a
296+
# misleading error (e.g. 415 Unsupported Media Type) that has
297+
# nothing to do with the real failure.
298+
audio_file.seek(0)
299+
293300
# Make the request
294301
endpoint_url = (
295302
env.WDOC_WHISPER_ENDPOINT.rstrip("/") + "/v1/audio/transcriptions"
296303
)
297-
response = requests.post(
298-
endpoint_url, files=files, data=data, headers=headers
299-
)
300-
response.raise_for_status()
304+
try:
305+
response = requests.post(
306+
endpoint_url, files=files, data=data, headers=headers
307+
)
308+
response.raise_for_status()
309+
except Exception as fallback_err:
310+
# Surface both errors: the fallback error is what the endpoint
311+
# returned, but the original litellm error is often the real
312+
# root cause (e.g. an auth/proxy misconfiguration) and would
313+
# otherwise be discarded, leaving a misleading message.
314+
raise Exception(
315+
f"Whisper transcription failed. Direct request to "
316+
f"'{endpoint_url}' raised: {fallback_err}. The initial "
317+
f"litellm.transcription attempt raised: {litellm_err}"
318+
) from fallback_err
301319
transcript = response.json()
302320

303321
t2 = time.time()

0 commit comments

Comments
 (0)