Skip to content
Merged
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
6 changes: 3 additions & 3 deletions py/src/braintrust/integrations/litellm/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,9 +10,9 @@ def patch_litellm() -> bool:
This wraps litellm.completion, litellm.acompletion, litellm.responses,
litellm.aresponses, litellm.image_generation, litellm.aimage_generation,
litellm.embedding, litellm.aembedding, litellm.moderation,
litellm.transcription, and litellm.atranscription to automatically
create Braintrust spans with detailed token metrics,
timing, and costs.
litellm.speech, litellm.aspeech, litellm.transcription, and
litellm.atranscription to automatically create Braintrust spans with
detailed token metrics, timing, and costs.

Returns:
True if LiteLLM was patched (or already patched), False if LiteLLM is not installed.
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

20 changes: 18 additions & 2 deletions py/src/braintrust/integrations/litellm/patchers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,12 +9,14 @@
_aembedding_wrapper_async,
_aimage_generation_wrapper_async,
_aresponses_wrapper_async,
_aspeech_wrapper_async,
_atranscription_wrapper_async,
_completion_wrapper,
_embedding_wrapper,
_image_generation_wrapper,
_moderation_wrapper,
_responses_wrapper,
_speech_wrapper,
_transcription_wrapper,
)

Expand DownExpand Up@@ -78,6 +80,18 @@ class LiteLLMModerationPatcher(FunctionWrapperPatcher):
wrapper = _moderation_wrapper


class LiteLLMSpeechPatcher(FunctionWrapperPatcher):
name = "litellm.speech"
target_path = "speech"
wrapper = _speech_wrapper


class LiteLLMAspeechPatcher(FunctionWrapperPatcher):
name = "litellm.aspeech"
target_path = "aspeech"
wrapper = _aspeech_wrapper_async


class LiteLLMTranscriptionPatcher(FunctionWrapperPatcher):
name = "litellm.transcription"
target_path = "transcription"
Expand All@@ -104,6 +118,8 @@ class LiteLLMATranscriptionPatcher(FunctionWrapperPatcher):
LiteLLMEmbeddingPatcher,
LiteLLMAembeddingPatcher,
LiteLLMModerationPatcher,
LiteLLMSpeechPatcher,
LiteLLMAspeechPatcher,
LiteLLMTranscriptionPatcher,
LiteLLMATranscriptionPatcher,
)
Expand All@@ -122,8 +138,8 @@ def wrap_litellm(litellm: Any) -> Any:
that exposes the same top-level callables such as ``completion``,
``acompletion``, ``responses``, ``aresponses``, ``image_generation``,
``aimage_generation``, ``embedding``, ``aembedding``, ``moderation``,
``transcription``, and ``atranscription``). Each patcher is applied
idempotently — calling
``speech``, ``aspeech``, ``transcription``, and ``atranscription``).
Each patcher is applied idempotently — calling
``wrap_litellm`` twice on the same object is safe.

Args:
Expand Down
60 changes: 60 additions & 0 deletions py/src/braintrust/integrations/litellm/test_litellm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,15 @@
TEST_AUDIO_FILE = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures", "test_audio.wav")


def _assert_speech_output_attachment(span) -> None:
assert span["output"]["type"] == "audio"
assert span["output"]["audio_size_bytes"] > 0
attachment = span["output"]["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"].startswith("audio/")
assert attachment.reference["filename"].startswith("generated_speech")


@pytest.fixture(autouse=True)
def _patch():
patch_litellm()
Expand DownExpand Up@@ -402,6 +411,57 @@ async def test_litellm_atranscription(memory_logger):
assert span["output"] == "you"


@pytest.mark.vcr
def test_litellm_speech(memory_logger):
assert not memory_logger.pop()

response = litellm.speech(
model="tts-1",
voice="alloy",
input="Hello, this is a test.",
response_format="mp3",
)

assert response
assert response.content

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == "tts-1"
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["response_format"] == "mp3"
assert span["metadata"]["provider"] == "litellm"
assert span["input"] == "Hello, this is a test."
_assert_speech_output_attachment(span)


@pytest.mark.vcr
@pytest.mark.asyncio
async def test_litellm_aspeech(memory_logger):
assert not memory_logger.pop()

response = await litellm.aspeech(
model="tts-1",
voice="alloy",
input="Hello, this is a test.",
response_format="mp3",
)

assert response
assert response.content

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == "tts-1"
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["response_format"] == "mp3"
assert span["metadata"]["provider"] == "litellm"
assert span["input"] == "Hello, this is a test."
_assert_speech_output_attachment(span)


@pytest.mark.vcr
@pytest.mark.asyncio
async def test_litellm_acompletion_with_system_prompt(memory_logger):
Expand Down
41 changes: 41 additions & 0 deletions py/src/braintrust/integrations/litellm/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@
from typing import Any

from braintrust.integrations.utils import (
_extract_audio_output,
_materialize_attachment,
_parse_openai_usage_metrics,
_prettify_response_params,
Expand DownExpand Up@@ -441,6 +442,46 @@ def _moderation_wrapper(wrapped, instance, args, kwargs):
return moderation_response


def _speech_wrapper(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.speech."""
updated_span_payload = _update_span_payload_from_params(kwargs, input_key="input")

with start_span(
**merge_dicts(dict(name="Speech", span_attributes={"type": SpanTypeAttribute.LLM}), updated_span_payload)
) as span:
start = time.time()
speech_response = wrapped(*args, **kwargs)
span.log(
metrics=_timing_metrics(start, time.time()),
output=_extract_audio_output(
speech_response,
response_format=kwargs.get("response_format"),
prefix="generated_speech",
),
)
return speech_response


async def _aspeech_wrapper_async(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.aspeech."""
updated_span_payload = _update_span_payload_from_params(kwargs, input_key="input")

with start_span(
**merge_dicts(dict(name="Speech", span_attributes={"type": SpanTypeAttribute.LLM}), updated_span_payload)
) as span:
start = time.time()
speech_response = await wrapped(*args, **kwargs)
span.log(
metrics=_timing_metrics(start, time.time()),
output=_extract_audio_output(
speech_response,
response_format=kwargs.get("response_format"),
prefix="generated_speech",
),
)
return speech_response


def _transcription_wrapper(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.transcription."""
updated_span_payload = _update_audio_span_payload_from_params(kwargs)
Expand Down
13 changes: 11 additions & 2 deletions py/src/braintrust/integrations/openai/test_openai.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1933,6 +1933,15 @@ def _assert_audio_input_attachment(span) -> None:
assert span["input"]["file"].reference["content_type"].startswith("audio/")


def _assert_audio_output_attachment(span) -> None:
assert span["output"]["type"] == "audio"
assert span["output"]["audio_size_bytes"] > 0
attachment = span["output"]["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"].startswith("audio/")
assert attachment.reference["filename"].startswith("generated_speech")


def _write_test_png(path: str, *, width: int = 64, height: int = 64) -> None:
"""Write a simple opaque red RGBA PNG without external dependencies."""

Expand DownExpand Up@@ -2067,7 +2076,7 @@ def test_openai_audio_speech(memory_logger):
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["provider"] == "openai"
assert span["input"] == "Hello, this is a test."
assert span["output"] == {"type": "audio"}
_assert_audio_output_attachment(span)


@pytest.mark.vcr
Expand DownExpand Up@@ -2175,7 +2184,7 @@ async def test_openai_audio_speech_async(memory_logger):
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["provider"] == "openai"
assert span["input"] == "Hello, this is a test."
assert span["output"] == {"type": "audio"}
_assert_audio_output_attachment(span)


@pytest.mark.asyncio
Expand Down
3 changes: 2 additions & 1 deletion py/src/braintrust/integrations/openai/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
from typing import Any

from braintrust.integrations.utils import (
_extract_audio_output,
_materialize_attachment,
_parse_openai_usage_metrics,
_prettify_response_params,
Expand DownExpand Up@@ -1276,7 +1277,7 @@ def __init__(self, create_fn: Callable[..., Any] | None, acreate_fn: Callable[..
super().__init__(create_fn, acreate_fn, "Speech")

def process_output(self, response: Any, span: Span):
span.log(output={"type": "audio"})
span.log(output=_extract_audio_output(response, prefix="generated_speech"))


class _AudioFileWrapper(BaseWrapper):
Expand Down
38 changes: 38 additions & 0 deletions py/src/braintrust/integrations/test_utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@
from braintrust.integrations.utils import (
_attachment_filename_for_mime_type,
_camel_to_snake,
_extract_audio_output,
_infer_audio_mime_type,
_is_supported_metric_value,
_log_and_end_span,
_log_error_and_end_span,
Expand DownExpand Up@@ -301,6 +303,42 @@ def test_materialize_attachment_returns_none_for_non_data_url_strings():
assert _materialize_attachment("https://example.com/image.png") is None


def test_infer_audio_mime_type_prefers_response_headers():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/mpeg; charset=binary"})
response = unittest.mock.Mock(response=raw_response)

assert _infer_audio_mime_type(response, response_format="wav") == "audio/mpeg"


def test_extract_audio_output_materializes_attachment_from_binary_response():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/mpeg"})
response = unittest.mock.Mock(content=b"audio-bytes", response=raw_response)

output = _extract_audio_output(response, prefix="generated_speech")

assert output["type"] == "audio"
assert output["mime_type"] == "audio/mpeg"
assert output["audio_size_bytes"] == len(b"audio-bytes")
attachment = output["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"] == "audio/mpeg"
assert attachment.reference["filename"] == "generated_speech.mp3"


def test_extract_audio_output_supports_mapping_with_raw_response_only():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/wav"}, content=b"wave")

output = _extract_audio_output({"response": raw_response}, prefix="generated_speech")

assert output["type"] == "audio"
assert output["mime_type"] == "audio/wav"
assert output["audio_size_bytes"] == len(b"wave")
attachment = output["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"] == "audio/wav"
assert attachment.reference["filename"] == "generated_speech.wav"


def test_serialize_response_format_with_pydantic_basemodel_subclass():
pydantic = pytest.importorskip("pydantic")

Expand Down
66 changes: 66 additions & 0 deletions py/src/braintrust/integrations/utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -343,6 +343,72 @@ def _materialize_attachment(
return None


_AUDIO_FORMAT_TO_MIME_TYPE = {
"mp3": "audio/mpeg",
"wav": "audio/wav",
"opus": "audio/opus",
"aac": "audio/aac",
"flac": "audio/flac",
"pcm": "audio/pcm",
}


def _infer_audio_mime_type(response: Any, response_format: Any = None) -> str:
raw_response = getattr(response, "response", None)
if raw_response is None and isinstance(response, Mapping):
raw_response = response.get("response")

headers = getattr(raw_response, "headers", None)
if headers is not None:
content_type = headers.get("content-type")
if isinstance(content_type, str) and content_type:
return content_type.split(";", 1)[0].strip()

if isinstance(response_format, str) and response_format:
normalized = response_format.lower()
return _AUDIO_FORMAT_TO_MIME_TYPE.get(
normalized,
normalized if "/" in normalized else f"audio/{normalized}",
)

return "application/octet-stream"


def _extract_audio_output(
response: Any,
*,
response_format: Any = None,
prefix: str = "generated_audio",
) -> dict[str, Any]:
audio_bytes = getattr(response, "content", None)
if not isinstance(audio_bytes, (bytes, bytearray)) and isinstance(response, Mapping):
raw_response = response.get("response")
audio_bytes = getattr(raw_response, "content", None)

if not isinstance(audio_bytes, (bytes, bytearray)):
return {"type": "audio"}

mime_type = _infer_audio_mime_type(response, response_format)
resolved_attachment = _materialize_attachment(
audio_bytes,
mime_type=mime_type,
prefix=prefix,
)
if resolved_attachment is None:
return {
"type": "audio",
"mime_type": mime_type,
"audio_size_bytes": len(audio_bytes),
}

return {
"type": "audio",
"mime_type": resolved_attachment.mime_type,
"audio_size_bytes": len(audio_bytes),
**resolved_attachment.multimodal_part_payload,
}


def _is_not_given(value: object) -> bool:
"""Return ``True`` when *value* is a provider ``NOT_GIVEN`` sentinel.

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(openai|litellm): attach speech outputs by AbhiPrasad · Pull Request #267 · braintrustdata/braintrust-sdk-python · GitHub
Skip to content
Merged
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
6 changes: 3 additions & 3 deletions py/src/braintrust/integrations/litellm/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,9 +10,9 @@ def patch_litellm() -> bool:
This wraps litellm.completion, litellm.acompletion, litellm.responses,
litellm.aresponses, litellm.image_generation, litellm.aimage_generation,
litellm.embedding, litellm.aembedding, litellm.moderation,
litellm.transcription, and litellm.atranscription to automatically
create Braintrust spans with detailed token metrics,
timing, and costs.
litellm.speech, litellm.aspeech, litellm.transcription, and
litellm.atranscription to automatically create Braintrust spans with
detailed token metrics, timing, and costs.

Returns:
True if LiteLLM was patched (or already patched), False if LiteLLM is not installed.
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

20 changes: 18 additions & 2 deletions py/src/braintrust/integrations/litellm/patchers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,12 +9,14 @@
_aembedding_wrapper_async,
_aimage_generation_wrapper_async,
_aresponses_wrapper_async,
_aspeech_wrapper_async,
_atranscription_wrapper_async,
_completion_wrapper,
_embedding_wrapper,
_image_generation_wrapper,
_moderation_wrapper,
_responses_wrapper,
_speech_wrapper,
_transcription_wrapper,
)

Expand DownExpand Up@@ -78,6 +80,18 @@ class LiteLLMModerationPatcher(FunctionWrapperPatcher):
wrapper = _moderation_wrapper


class LiteLLMSpeechPatcher(FunctionWrapperPatcher):
name = "litellm.speech"
target_path = "speech"
wrapper = _speech_wrapper


class LiteLLMAspeechPatcher(FunctionWrapperPatcher):
name = "litellm.aspeech"
target_path = "aspeech"
wrapper = _aspeech_wrapper_async


class LiteLLMTranscriptionPatcher(FunctionWrapperPatcher):
name = "litellm.transcription"
target_path = "transcription"
Expand All@@ -104,6 +118,8 @@ class LiteLLMATranscriptionPatcher(FunctionWrapperPatcher):
LiteLLMEmbeddingPatcher,
LiteLLMAembeddingPatcher,
LiteLLMModerationPatcher,
LiteLLMSpeechPatcher,
LiteLLMAspeechPatcher,
LiteLLMTranscriptionPatcher,
LiteLLMATranscriptionPatcher,
)
Expand All@@ -122,8 +138,8 @@ def wrap_litellm(litellm: Any) -> Any:
that exposes the same top-level callables such as ``completion``,
``acompletion``, ``responses``, ``aresponses``, ``image_generation``,
``aimage_generation``, ``embedding``, ``aembedding``, ``moderation``,
``transcription``, and ``atranscription``). Each patcher is applied
idempotently — calling
``speech``, ``aspeech``, ``transcription``, and ``atranscription``).
Each patcher is applied idempotently — calling
``wrap_litellm`` twice on the same object is safe.

Args:
Expand Down
60 changes: 60 additions & 0 deletions py/src/braintrust/integrations/litellm/test_litellm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,15 @@
TEST_AUDIO_FILE = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures", "test_audio.wav")


def _assert_speech_output_attachment(span) -> None:
assert span["output"]["type"] == "audio"
assert span["output"]["audio_size_bytes"] > 0
attachment = span["output"]["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"].startswith("audio/")
assert attachment.reference["filename"].startswith("generated_speech")


@pytest.fixture(autouse=True)
def _patch():
patch_litellm()
Expand DownExpand Up@@ -402,6 +411,57 @@ async def test_litellm_atranscription(memory_logger):
assert span["output"] == "you"


@pytest.mark.vcr
def test_litellm_speech(memory_logger):
assert not memory_logger.pop()

response = litellm.speech(
model="tts-1",
voice="alloy",
input="Hello, this is a test.",
response_format="mp3",
)

assert response
assert response.content

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == "tts-1"
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["response_format"] == "mp3"
assert span["metadata"]["provider"] == "litellm"
assert span["input"] == "Hello, this is a test."
_assert_speech_output_attachment(span)


@pytest.mark.vcr
@pytest.mark.asyncio
async def test_litellm_aspeech(memory_logger):
assert not memory_logger.pop()

response = await litellm.aspeech(
model="tts-1",
voice="alloy",
input="Hello, this is a test.",
response_format="mp3",
)

assert response
assert response.content

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == "tts-1"
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["response_format"] == "mp3"
assert span["metadata"]["provider"] == "litellm"
assert span["input"] == "Hello, this is a test."
_assert_speech_output_attachment(span)


@pytest.mark.vcr
@pytest.mark.asyncio
async def test_litellm_acompletion_with_system_prompt(memory_logger):
Expand Down
41 changes: 41 additions & 0 deletions py/src/braintrust/integrations/litellm/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@
from typing import Any

from braintrust.integrations.utils import (
_extract_audio_output,
_materialize_attachment,
_parse_openai_usage_metrics,
_prettify_response_params,
Expand DownExpand Up@@ -441,6 +442,46 @@ def _moderation_wrapper(wrapped, instance, args, kwargs):
return moderation_response


def _speech_wrapper(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.speech."""
updated_span_payload = _update_span_payload_from_params(kwargs, input_key="input")

with start_span(
**merge_dicts(dict(name="Speech", span_attributes={"type": SpanTypeAttribute.LLM}), updated_span_payload)
) as span:
start = time.time()
speech_response = wrapped(*args, **kwargs)
span.log(
metrics=_timing_metrics(start, time.time()),
output=_extract_audio_output(
speech_response,
response_format=kwargs.get("response_format"),
prefix="generated_speech",
),
)
return speech_response


async def _aspeech_wrapper_async(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.aspeech."""
updated_span_payload = _update_span_payload_from_params(kwargs, input_key="input")

with start_span(
**merge_dicts(dict(name="Speech", span_attributes={"type": SpanTypeAttribute.LLM}), updated_span_payload)
) as span:
start = time.time()
speech_response = await wrapped(*args, **kwargs)
span.log(
metrics=_timing_metrics(start, time.time()),
output=_extract_audio_output(
speech_response,
response_format=kwargs.get("response_format"),
prefix="generated_speech",
),
)
return speech_response


def _transcription_wrapper(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.transcription."""
updated_span_payload = _update_audio_span_payload_from_params(kwargs)
Expand Down
13 changes: 11 additions & 2 deletions py/src/braintrust/integrations/openai/test_openai.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1933,6 +1933,15 @@ def _assert_audio_input_attachment(span) -> None:
assert span["input"]["file"].reference["content_type"].startswith("audio/")


def _assert_audio_output_attachment(span) -> None:
assert span["output"]["type"] == "audio"
assert span["output"]["audio_size_bytes"] > 0
attachment = span["output"]["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"].startswith("audio/")
assert attachment.reference["filename"].startswith("generated_speech")


def _write_test_png(path: str, *, width: int = 64, height: int = 64) -> None:
"""Write a simple opaque red RGBA PNG without external dependencies."""

Expand DownExpand Up@@ -2067,7 +2076,7 @@ def test_openai_audio_speech(memory_logger):
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["provider"] == "openai"
assert span["input"] == "Hello, this is a test."
assert span["output"] == {"type": "audio"}
_assert_audio_output_attachment(span)


@pytest.mark.vcr
Expand DownExpand Up@@ -2175,7 +2184,7 @@ async def test_openai_audio_speech_async(memory_logger):
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["provider"] == "openai"
assert span["input"] == "Hello, this is a test."
assert span["output"] == {"type": "audio"}
_assert_audio_output_attachment(span)


@pytest.mark.asyncio
Expand Down
3 changes: 2 additions & 1 deletion py/src/braintrust/integrations/openai/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
from typing import Any

from braintrust.integrations.utils import (
_extract_audio_output,
_materialize_attachment,
_parse_openai_usage_metrics,
_prettify_response_params,
Expand DownExpand Up@@ -1276,7 +1277,7 @@ def __init__(self, create_fn: Callable[..., Any] | None, acreate_fn: Callable[..
super().__init__(create_fn, acreate_fn, "Speech")

def process_output(self, response: Any, span: Span):
span.log(output={"type": "audio"})
span.log(output=_extract_audio_output(response, prefix="generated_speech"))


class _AudioFileWrapper(BaseWrapper):
Expand Down
38 changes: 38 additions & 0 deletions py/src/braintrust/integrations/test_utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@
from braintrust.integrations.utils import (
_attachment_filename_for_mime_type,
_camel_to_snake,
_extract_audio_output,
_infer_audio_mime_type,
_is_supported_metric_value,
_log_and_end_span,
_log_error_and_end_span,
Expand DownExpand Up@@ -301,6 +303,42 @@ def test_materialize_attachment_returns_none_for_non_data_url_strings():
assert _materialize_attachment("https://example.com/image.png") is None


def test_infer_audio_mime_type_prefers_response_headers():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/mpeg; charset=binary"})
response = unittest.mock.Mock(response=raw_response)

assert _infer_audio_mime_type(response, response_format="wav") == "audio/mpeg"


def test_extract_audio_output_materializes_attachment_from_binary_response():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/mpeg"})
response = unittest.mock.Mock(content=b"audio-bytes", response=raw_response)

output = _extract_audio_output(response, prefix="generated_speech")

assert output["type"] == "audio"
assert output["mime_type"] == "audio/mpeg"
assert output["audio_size_bytes"] == len(b"audio-bytes")
attachment = output["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"] == "audio/mpeg"
assert attachment.reference["filename"] == "generated_speech.mp3"


def test_extract_audio_output_supports_mapping_with_raw_response_only():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/wav"}, content=b"wave")

output = _extract_audio_output({"response": raw_response}, prefix="generated_speech")

assert output["type"] == "audio"
assert output["mime_type"] == "audio/wav"
assert output["audio_size_bytes"] == len(b"wave")
attachment = output["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"] == "audio/wav"
assert attachment.reference["filename"] == "generated_speech.wav"


def test_serialize_response_format_with_pydantic_basemodel_subclass():
pydantic = pytest.importorskip("pydantic")

Expand Down
66 changes: 66 additions & 0 deletions py/src/braintrust/integrations/utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -343,6 +343,72 @@ def _materialize_attachment(
return None


_AUDIO_FORMAT_TO_MIME_TYPE = {
"mp3": "audio/mpeg",
"wav": "audio/wav",
"opus": "audio/opus",
"aac": "audio/aac",
"flac": "audio/flac",
"pcm": "audio/pcm",
}


def _infer_audio_mime_type(response: Any, response_format: Any = None) -> str:
raw_response = getattr(response, "response", None)
if raw_response is None and isinstance(response, Mapping):
raw_response = response.get("response")

headers = getattr(raw_response, "headers", None)
if headers is not None:
content_type = headers.get("content-type")
if isinstance(content_type, str) and content_type:
return content_type.split(";", 1)[0].strip()

if isinstance(response_format, str) and response_format:
normalized = response_format.lower()
return _AUDIO_FORMAT_TO_MIME_TYPE.get(
normalized,
normalized if "/" in normalized else f"audio/{normalized}",
)

return "application/octet-stream"


def _extract_audio_output(
response: Any,
*,
response_format: Any = None,
prefix: str = "generated_audio",
) -> dict[str, Any]:
audio_bytes = getattr(response, "content", None)
if not isinstance(audio_bytes, (bytes, bytearray)) and isinstance(response, Mapping):
raw_response = response.get("response")
audio_bytes = getattr(raw_response, "content", None)

if not isinstance(audio_bytes, (bytes, bytearray)):
return {"type": "audio"}

mime_type = _infer_audio_mime_type(response, response_format)
resolved_attachment = _materialize_attachment(
audio_bytes,
mime_type=mime_type,
prefix=prefix,
)
if resolved_attachment is None:
return {
"type": "audio",
"mime_type": mime_type,
"audio_size_bytes": len(audio_bytes),
}

return {
"type": "audio",
"mime_type": resolved_attachment.mime_type,
"audio_size_bytes": len(audio_bytes),
**resolved_attachment.multimodal_part_payload,
}


def _is_not_given(value: object) -> bool:
"""Return ``True`` when *value* is a provider ``NOT_GIVEN`` sentinel.

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(openai|litellm): attach speech outputs by AbhiPrasad · Pull Request #267 · braintrustdata/braintrust-sdk-python · GitHub
Skip to content
Merged
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
6 changes: 3 additions & 3 deletions py/src/braintrust/integrations/litellm/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,9 +10,9 @@ def patch_litellm() -> bool:
This wraps litellm.completion, litellm.acompletion, litellm.responses,
litellm.aresponses, litellm.image_generation, litellm.aimage_generation,
litellm.embedding, litellm.aembedding, litellm.moderation,
litellm.transcription, and litellm.atranscription to automatically
create Braintrust spans with detailed token metrics,
timing, and costs.
litellm.speech, litellm.aspeech, litellm.transcription, and
litellm.atranscription to automatically create Braintrust spans with
detailed token metrics, timing, and costs.

Returns:
True if LiteLLM was patched (or already patched), False if LiteLLM is not installed.
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

20 changes: 18 additions & 2 deletions py/src/braintrust/integrations/litellm/patchers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,12 +9,14 @@
_aembedding_wrapper_async,
_aimage_generation_wrapper_async,
_aresponses_wrapper_async,
_aspeech_wrapper_async,
_atranscription_wrapper_async,
_completion_wrapper,
_embedding_wrapper,
_image_generation_wrapper,
_moderation_wrapper,
_responses_wrapper,
_speech_wrapper,
_transcription_wrapper,
)

Expand DownExpand Up@@ -78,6 +80,18 @@ class LiteLLMModerationPatcher(FunctionWrapperPatcher):
wrapper = _moderation_wrapper


class LiteLLMSpeechPatcher(FunctionWrapperPatcher):
name = "litellm.speech"
target_path = "speech"
wrapper = _speech_wrapper


class LiteLLMAspeechPatcher(FunctionWrapperPatcher):
name = "litellm.aspeech"
target_path = "aspeech"
wrapper = _aspeech_wrapper_async


class LiteLLMTranscriptionPatcher(FunctionWrapperPatcher):
name = "litellm.transcription"
target_path = "transcription"
Expand All@@ -104,6 +118,8 @@ class LiteLLMATranscriptionPatcher(FunctionWrapperPatcher):
LiteLLMEmbeddingPatcher,
LiteLLMAembeddingPatcher,
LiteLLMModerationPatcher,
LiteLLMSpeechPatcher,
LiteLLMAspeechPatcher,
LiteLLMTranscriptionPatcher,
LiteLLMATranscriptionPatcher,
)
Expand All@@ -122,8 +138,8 @@ def wrap_litellm(litellm: Any) -> Any:
that exposes the same top-level callables such as ``completion``,
``acompletion``, ``responses``, ``aresponses``, ``image_generation``,
``aimage_generation``, ``embedding``, ``aembedding``, ``moderation``,
``transcription``, and ``atranscription``). Each patcher is applied
idempotently — calling
``speech``, ``aspeech``, ``transcription``, and ``atranscription``).
Each patcher is applied idempotently — calling
``wrap_litellm`` twice on the same object is safe.

Args:
Expand Down
60 changes: 60 additions & 0 deletions py/src/braintrust/integrations/litellm/test_litellm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,15 @@
TEST_AUDIO_FILE = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures", "test_audio.wav")


def _assert_speech_output_attachment(span) -> None:
assert span["output"]["type"] == "audio"
assert span["output"]["audio_size_bytes"] > 0
attachment = span["output"]["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"].startswith("audio/")
assert attachment.reference["filename"].startswith("generated_speech")


@pytest.fixture(autouse=True)
def _patch():
patch_litellm()
Expand DownExpand Up@@ -402,6 +411,57 @@ async def test_litellm_atranscription(memory_logger):
assert span["output"] == "you"


@pytest.mark.vcr
def test_litellm_speech(memory_logger):
assert not memory_logger.pop()

response = litellm.speech(
model="tts-1",
voice="alloy",
input="Hello, this is a test.",
response_format="mp3",
)

assert response
assert response.content

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == "tts-1"
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["response_format"] == "mp3"
assert span["metadata"]["provider"] == "litellm"
assert span["input"] == "Hello, this is a test."
_assert_speech_output_attachment(span)


@pytest.mark.vcr
@pytest.mark.asyncio
async def test_litellm_aspeech(memory_logger):
assert not memory_logger.pop()

response = await litellm.aspeech(
model="tts-1",
voice="alloy",
input="Hello, this is a test.",
response_format="mp3",
)

assert response
assert response.content

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == "tts-1"
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["response_format"] == "mp3"
assert span["metadata"]["provider"] == "litellm"
assert span["input"] == "Hello, this is a test."
_assert_speech_output_attachment(span)


@pytest.mark.vcr
@pytest.mark.asyncio
async def test_litellm_acompletion_with_system_prompt(memory_logger):
Expand Down
41 changes: 41 additions & 0 deletions py/src/braintrust/integrations/litellm/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@
from typing import Any

from braintrust.integrations.utils import (
_extract_audio_output,
_materialize_attachment,
_parse_openai_usage_metrics,
_prettify_response_params,
Expand DownExpand Up@@ -441,6 +442,46 @@ def _moderation_wrapper(wrapped, instance, args, kwargs):
return moderation_response


def _speech_wrapper(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.speech."""
updated_span_payload = _update_span_payload_from_params(kwargs, input_key="input")

with start_span(
**merge_dicts(dict(name="Speech", span_attributes={"type": SpanTypeAttribute.LLM}), updated_span_payload)
) as span:
start = time.time()
speech_response = wrapped(*args, **kwargs)
span.log(
metrics=_timing_metrics(start, time.time()),
output=_extract_audio_output(
speech_response,
response_format=kwargs.get("response_format"),
prefix="generated_speech",
),
)
return speech_response


async def _aspeech_wrapper_async(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.aspeech."""
updated_span_payload = _update_span_payload_from_params(kwargs, input_key="input")

with start_span(
**merge_dicts(dict(name="Speech", span_attributes={"type": SpanTypeAttribute.LLM}), updated_span_payload)
) as span:
start = time.time()
speech_response = await wrapped(*args, **kwargs)
span.log(
metrics=_timing_metrics(start, time.time()),
output=_extract_audio_output(
speech_response,
response_format=kwargs.get("response_format"),
prefix="generated_speech",
),
)
return speech_response


def _transcription_wrapper(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.transcription."""
updated_span_payload = _update_audio_span_payload_from_params(kwargs)
Expand Down
13 changes: 11 additions & 2 deletions py/src/braintrust/integrations/openai/test_openai.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1933,6 +1933,15 @@ def _assert_audio_input_attachment(span) -> None:
assert span["input"]["file"].reference["content_type"].startswith("audio/")


def _assert_audio_output_attachment(span) -> None:
assert span["output"]["type"] == "audio"
assert span["output"]["audio_size_bytes"] > 0
attachment = span["output"]["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"].startswith("audio/")
assert attachment.reference["filename"].startswith("generated_speech")


def _write_test_png(path: str, *, width: int = 64, height: int = 64) -> None:
"""Write a simple opaque red RGBA PNG without external dependencies."""

Expand DownExpand Up@@ -2067,7 +2076,7 @@ def test_openai_audio_speech(memory_logger):
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["provider"] == "openai"
assert span["input"] == "Hello, this is a test."
assert span["output"] == {"type": "audio"}
_assert_audio_output_attachment(span)


@pytest.mark.vcr
Expand DownExpand Up@@ -2175,7 +2184,7 @@ async def test_openai_audio_speech_async(memory_logger):
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["provider"] == "openai"
assert span["input"] == "Hello, this is a test."
assert span["output"] == {"type": "audio"}
_assert_audio_output_attachment(span)


@pytest.mark.asyncio
Expand Down
3 changes: 2 additions & 1 deletion py/src/braintrust/integrations/openai/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
from typing import Any

from braintrust.integrations.utils import (
_extract_audio_output,
_materialize_attachment,
_parse_openai_usage_metrics,
_prettify_response_params,
Expand DownExpand Up@@ -1276,7 +1277,7 @@ def __init__(self, create_fn: Callable[..., Any] | None, acreate_fn: Callable[..
super().__init__(create_fn, acreate_fn, "Speech")

def process_output(self, response: Any, span: Span):
span.log(output={"type": "audio"})
span.log(output=_extract_audio_output(response, prefix="generated_speech"))


class _AudioFileWrapper(BaseWrapper):
Expand Down
38 changes: 38 additions & 0 deletions py/src/braintrust/integrations/test_utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@
from braintrust.integrations.utils import (
_attachment_filename_for_mime_type,
_camel_to_snake,
_extract_audio_output,
_infer_audio_mime_type,
_is_supported_metric_value,
_log_and_end_span,
_log_error_and_end_span,
Expand DownExpand Up@@ -301,6 +303,42 @@ def test_materialize_attachment_returns_none_for_non_data_url_strings():
assert _materialize_attachment("https://example.com/image.png") is None


def test_infer_audio_mime_type_prefers_response_headers():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/mpeg; charset=binary"})
response = unittest.mock.Mock(response=raw_response)

assert _infer_audio_mime_type(response, response_format="wav") == "audio/mpeg"


def test_extract_audio_output_materializes_attachment_from_binary_response():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/mpeg"})
response = unittest.mock.Mock(content=b"audio-bytes", response=raw_response)

output = _extract_audio_output(response, prefix="generated_speech")

assert output["type"] == "audio"
assert output["mime_type"] == "audio/mpeg"
assert output["audio_size_bytes"] == len(b"audio-bytes")
attachment = output["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"] == "audio/mpeg"
assert attachment.reference["filename"] == "generated_speech.mp3"


def test_extract_audio_output_supports_mapping_with_raw_response_only():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/wav"}, content=b"wave")

output = _extract_audio_output({"response": raw_response}, prefix="generated_speech")

assert output["type"] == "audio"
assert output["mime_type"] == "audio/wav"
assert output["audio_size_bytes"] == len(b"wave")
attachment = output["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"] == "audio/wav"
assert attachment.reference["filename"] == "generated_speech.wav"


def test_serialize_response_format_with_pydantic_basemodel_subclass():
pydantic = pytest.importorskip("pydantic")

Expand Down
66 changes: 66 additions & 0 deletions py/src/braintrust/integrations/utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -343,6 +343,72 @@ def _materialize_attachment(
return None


_AUDIO_FORMAT_TO_MIME_TYPE = {
"mp3": "audio/mpeg",
"wav": "audio/wav",
"opus": "audio/opus",
"aac": "audio/aac",
"flac": "audio/flac",
"pcm": "audio/pcm",
}


def _infer_audio_mime_type(response: Any, response_format: Any = None) -> str:
raw_response = getattr(response, "response", None)
if raw_response is None and isinstance(response, Mapping):
raw_response = response.get("response")

headers = getattr(raw_response, "headers", None)
if headers is not None:
content_type = headers.get("content-type")
if isinstance(content_type, str) and content_type:
return content_type.split(";", 1)[0].strip()

if isinstance(response_format, str) and response_format:
normalized = response_format.lower()
return _AUDIO_FORMAT_TO_MIME_TYPE.get(
normalized,
normalized if "/" in normalized else f"audio/{normalized}",
)

return "application/octet-stream"


def _extract_audio_output(
response: Any,
*,
response_format: Any = None,
prefix: str = "generated_audio",
) -> dict[str, Any]:
audio_bytes = getattr(response, "content", None)
if not isinstance(audio_bytes, (bytes, bytearray)) and isinstance(response, Mapping):
raw_response = response.get("response")
audio_bytes = getattr(raw_response, "content", None)

if not isinstance(audio_bytes, (bytes, bytearray)):
return {"type": "audio"}

mime_type = _infer_audio_mime_type(response, response_format)
resolved_attachment = _materialize_attachment(
audio_bytes,
mime_type=mime_type,
prefix=prefix,
)
if resolved_attachment is None:
return {
"type": "audio",
"mime_type": mime_type,
"audio_size_bytes": len(audio_bytes),
}

return {
"type": "audio",
"mime_type": resolved_attachment.mime_type,
"audio_size_bytes": len(audio_bytes),
**resolved_attachment.multimodal_part_payload,
}


def _is_not_given(value: object) -> bool:
"""Return ``True`` when *value* is a provider ``NOT_GIVEN`` sentinel.

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(openai|litellm): attach speech outputs by AbhiPrasad · Pull Request #267 · braintrustdata/braintrust-sdk-python · GitHub
Skip to content
Merged
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
6 changes: 3 additions & 3 deletions py/src/braintrust/integrations/litellm/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,9 +10,9 @@ def patch_litellm() -> bool:
This wraps litellm.completion, litellm.acompletion, litellm.responses,
litellm.aresponses, litellm.image_generation, litellm.aimage_generation,
litellm.embedding, litellm.aembedding, litellm.moderation,
litellm.transcription, and litellm.atranscription to automatically
create Braintrust spans with detailed token metrics,
timing, and costs.
litellm.speech, litellm.aspeech, litellm.transcription, and
litellm.atranscription to automatically create Braintrust spans with
detailed token metrics, timing, and costs.

Returns:
True if LiteLLM was patched (or already patched), False if LiteLLM is not installed.
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

20 changes: 18 additions & 2 deletions py/src/braintrust/integrations/litellm/patchers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,12 +9,14 @@
_aembedding_wrapper_async,
_aimage_generation_wrapper_async,
_aresponses_wrapper_async,
_aspeech_wrapper_async,
_atranscription_wrapper_async,
_completion_wrapper,
_embedding_wrapper,
_image_generation_wrapper,
_moderation_wrapper,
_responses_wrapper,
_speech_wrapper,
_transcription_wrapper,
)

Expand DownExpand Up@@ -78,6 +80,18 @@ class LiteLLMModerationPatcher(FunctionWrapperPatcher):
wrapper = _moderation_wrapper


class LiteLLMSpeechPatcher(FunctionWrapperPatcher):
name = "litellm.speech"
target_path = "speech"
wrapper = _speech_wrapper


class LiteLLMAspeechPatcher(FunctionWrapperPatcher):
name = "litellm.aspeech"
target_path = "aspeech"
wrapper = _aspeech_wrapper_async


class LiteLLMTranscriptionPatcher(FunctionWrapperPatcher):
name = "litellm.transcription"
target_path = "transcription"
Expand All@@ -104,6 +118,8 @@ class LiteLLMATranscriptionPatcher(FunctionWrapperPatcher):
LiteLLMEmbeddingPatcher,
LiteLLMAembeddingPatcher,
LiteLLMModerationPatcher,
LiteLLMSpeechPatcher,
LiteLLMAspeechPatcher,
LiteLLMTranscriptionPatcher,
LiteLLMATranscriptionPatcher,
)
Expand All@@ -122,8 +138,8 @@ def wrap_litellm(litellm: Any) -> Any:
that exposes the same top-level callables such as ``completion``,
``acompletion``, ``responses``, ``aresponses``, ``image_generation``,
``aimage_generation``, ``embedding``, ``aembedding``, ``moderation``,
``transcription``, and ``atranscription``). Each patcher is applied
idempotently — calling
``speech``, ``aspeech``, ``transcription``, and ``atranscription``).
Each patcher is applied idempotently — calling
``wrap_litellm`` twice on the same object is safe.

Args:
Expand Down
60 changes: 60 additions & 0 deletions py/src/braintrust/integrations/litellm/test_litellm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,15 @@
TEST_AUDIO_FILE = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures", "test_audio.wav")


def _assert_speech_output_attachment(span) -> None:
assert span["output"]["type"] == "audio"
assert span["output"]["audio_size_bytes"] > 0
attachment = span["output"]["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"].startswith("audio/")
assert attachment.reference["filename"].startswith("generated_speech")


@pytest.fixture(autouse=True)
def _patch():
patch_litellm()
Expand DownExpand Up@@ -402,6 +411,57 @@ async def test_litellm_atranscription(memory_logger):
assert span["output"] == "you"


@pytest.mark.vcr
def test_litellm_speech(memory_logger):
assert not memory_logger.pop()

response = litellm.speech(
model="tts-1",
voice="alloy",
input="Hello, this is a test.",
response_format="mp3",
)

assert response
assert response.content

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == "tts-1"
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["response_format"] == "mp3"
assert span["metadata"]["provider"] == "litellm"
assert span["input"] == "Hello, this is a test."
_assert_speech_output_attachment(span)


@pytest.mark.vcr
@pytest.mark.asyncio
async def test_litellm_aspeech(memory_logger):
assert not memory_logger.pop()

response = await litellm.aspeech(
model="tts-1",
voice="alloy",
input="Hello, this is a test.",
response_format="mp3",
)

assert response
assert response.content

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == "tts-1"
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["response_format"] == "mp3"
assert span["metadata"]["provider"] == "litellm"
assert span["input"] == "Hello, this is a test."
_assert_speech_output_attachment(span)


@pytest.mark.vcr
@pytest.mark.asyncio
async def test_litellm_acompletion_with_system_prompt(memory_logger):
Expand Down
41 changes: 41 additions & 0 deletions py/src/braintrust/integrations/litellm/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@
from typing import Any

from braintrust.integrations.utils import (
_extract_audio_output,
_materialize_attachment,
_parse_openai_usage_metrics,
_prettify_response_params,
Expand DownExpand Up@@ -441,6 +442,46 @@ def _moderation_wrapper(wrapped, instance, args, kwargs):
return moderation_response


def _speech_wrapper(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.speech."""
updated_span_payload = _update_span_payload_from_params(kwargs, input_key="input")

with start_span(
**merge_dicts(dict(name="Speech", span_attributes={"type": SpanTypeAttribute.LLM}), updated_span_payload)
) as span:
start = time.time()
speech_response = wrapped(*args, **kwargs)
span.log(
metrics=_timing_metrics(start, time.time()),
output=_extract_audio_output(
speech_response,
response_format=kwargs.get("response_format"),
prefix="generated_speech",
),
)
return speech_response


async def _aspeech_wrapper_async(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.aspeech."""
updated_span_payload = _update_span_payload_from_params(kwargs, input_key="input")

with start_span(
**merge_dicts(dict(name="Speech", span_attributes={"type": SpanTypeAttribute.LLM}), updated_span_payload)
) as span:
start = time.time()
speech_response = await wrapped(*args, **kwargs)
span.log(
metrics=_timing_metrics(start, time.time()),
output=_extract_audio_output(
speech_response,
response_format=kwargs.get("response_format"),
prefix="generated_speech",
),
)
return speech_response


def _transcription_wrapper(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.transcription."""
updated_span_payload = _update_audio_span_payload_from_params(kwargs)
Expand Down
13 changes: 11 additions & 2 deletions py/src/braintrust/integrations/openai/test_openai.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1933,6 +1933,15 @@ def _assert_audio_input_attachment(span) -> None:
assert span["input"]["file"].reference["content_type"].startswith("audio/")


def _assert_audio_output_attachment(span) -> None:
assert span["output"]["type"] == "audio"
assert span["output"]["audio_size_bytes"] > 0
attachment = span["output"]["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"].startswith("audio/")
assert attachment.reference["filename"].startswith("generated_speech")


def _write_test_png(path: str, *, width: int = 64, height: int = 64) -> None:
"""Write a simple opaque red RGBA PNG without external dependencies."""

Expand DownExpand Up@@ -2067,7 +2076,7 @@ def test_openai_audio_speech(memory_logger):
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["provider"] == "openai"
assert span["input"] == "Hello, this is a test."
assert span["output"] == {"type": "audio"}
_assert_audio_output_attachment(span)


@pytest.mark.vcr
Expand DownExpand Up@@ -2175,7 +2184,7 @@ async def test_openai_audio_speech_async(memory_logger):
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["provider"] == "openai"
assert span["input"] == "Hello, this is a test."
assert span["output"] == {"type": "audio"}
_assert_audio_output_attachment(span)


@pytest.mark.asyncio
Expand Down
3 changes: 2 additions & 1 deletion py/src/braintrust/integrations/openai/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
from typing import Any

from braintrust.integrations.utils import (
_extract_audio_output,
_materialize_attachment,
_parse_openai_usage_metrics,
_prettify_response_params,
Expand DownExpand Up@@ -1276,7 +1277,7 @@ def __init__(self, create_fn: Callable[..., Any] | None, acreate_fn: Callable[..
super().__init__(create_fn, acreate_fn, "Speech")

def process_output(self, response: Any, span: Span):
span.log(output={"type": "audio"})
span.log(output=_extract_audio_output(response, prefix="generated_speech"))


class _AudioFileWrapper(BaseWrapper):
Expand Down
38 changes: 38 additions & 0 deletions py/src/braintrust/integrations/test_utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@
from braintrust.integrations.utils import (
_attachment_filename_for_mime_type,
_camel_to_snake,
_extract_audio_output,
_infer_audio_mime_type,
_is_supported_metric_value,
_log_and_end_span,
_log_error_and_end_span,
Expand DownExpand Up@@ -301,6 +303,42 @@ def test_materialize_attachment_returns_none_for_non_data_url_strings():
assert _materialize_attachment("https://example.com/image.png") is None


def test_infer_audio_mime_type_prefers_response_headers():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/mpeg; charset=binary"})
response = unittest.mock.Mock(response=raw_response)

assert _infer_audio_mime_type(response, response_format="wav") == "audio/mpeg"


def test_extract_audio_output_materializes_attachment_from_binary_response():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/mpeg"})
response = unittest.mock.Mock(content=b"audio-bytes", response=raw_response)

output = _extract_audio_output(response, prefix="generated_speech")

assert output["type"] == "audio"
assert output["mime_type"] == "audio/mpeg"
assert output["audio_size_bytes"] == len(b"audio-bytes")
attachment = output["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"] == "audio/mpeg"
assert attachment.reference["filename"] == "generated_speech.mp3"


def test_extract_audio_output_supports_mapping_with_raw_response_only():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/wav"}, content=b"wave")

output = _extract_audio_output({"response": raw_response}, prefix="generated_speech")

assert output["type"] == "audio"
assert output["mime_type"] == "audio/wav"
assert output["audio_size_bytes"] == len(b"wave")
attachment = output["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"] == "audio/wav"
assert attachment.reference["filename"] == "generated_speech.wav"


def test_serialize_response_format_with_pydantic_basemodel_subclass():
pydantic = pytest.importorskip("pydantic")

Expand Down
66 changes: 66 additions & 0 deletions py/src/braintrust/integrations/utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -343,6 +343,72 @@ def _materialize_attachment(
return None


_AUDIO_FORMAT_TO_MIME_TYPE = {
"mp3": "audio/mpeg",
"wav": "audio/wav",
"opus": "audio/opus",
"aac": "audio/aac",
"flac": "audio/flac",
"pcm": "audio/pcm",
}


def _infer_audio_mime_type(response: Any, response_format: Any = None) -> str:
raw_response = getattr(response, "response", None)
if raw_response is None and isinstance(response, Mapping):
raw_response = response.get("response")

headers = getattr(raw_response, "headers", None)
if headers is not None:
content_type = headers.get("content-type")
if isinstance(content_type, str) and content_type:
return content_type.split(";", 1)[0].strip()

if isinstance(response_format, str) and response_format:
normalized = response_format.lower()
return _AUDIO_FORMAT_TO_MIME_TYPE.get(
normalized,
normalized if "/" in normalized else f"audio/{normalized}",
)

return "application/octet-stream"


def _extract_audio_output(
response: Any,
*,
response_format: Any = None,
prefix: str = "generated_audio",
) -> dict[str, Any]:
audio_bytes = getattr(response, "content", None)
if not isinstance(audio_bytes, (bytes, bytearray)) and isinstance(response, Mapping):
raw_response = response.get("response")
audio_bytes = getattr(raw_response, "content", None)

if not isinstance(audio_bytes, (bytes, bytearray)):
return {"type": "audio"}

mime_type = _infer_audio_mime_type(response, response_format)
resolved_attachment = _materialize_attachment(
audio_bytes,
mime_type=mime_type,
prefix=prefix,
)
if resolved_attachment is None:
return {
"type": "audio",
"mime_type": mime_type,
"audio_size_bytes": len(audio_bytes),
}

return {
"type": "audio",
"mime_type": resolved_attachment.mime_type,
"audio_size_bytes": len(audio_bytes),
**resolved_attachment.multimodal_part_payload,
}


def _is_not_given(value: object) -> bool:
"""Return ``True`` when *value* is a provider ``NOT_GIVEN`` sentinel.

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(openai|litellm): attach speech outputs by AbhiPrasad · Pull Request #267 · braintrustdata/braintrust-sdk-python · GitHub
Skip to content
Merged
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
6 changes: 3 additions & 3 deletions py/src/braintrust/integrations/litellm/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,9 +10,9 @@ def patch_litellm() -> bool:
This wraps litellm.completion, litellm.acompletion, litellm.responses,
litellm.aresponses, litellm.image_generation, litellm.aimage_generation,
litellm.embedding, litellm.aembedding, litellm.moderation,
litellm.transcription, and litellm.atranscription to automatically
create Braintrust spans with detailed token metrics,
timing, and costs.
litellm.speech, litellm.aspeech, litellm.transcription, and
litellm.atranscription to automatically create Braintrust spans with
detailed token metrics, timing, and costs.

Returns:
True if LiteLLM was patched (or already patched), False if LiteLLM is not installed.
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

20 changes: 18 additions & 2 deletions py/src/braintrust/integrations/litellm/patchers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,12 +9,14 @@
_aembedding_wrapper_async,
_aimage_generation_wrapper_async,
_aresponses_wrapper_async,
_aspeech_wrapper_async,
_atranscription_wrapper_async,
_completion_wrapper,
_embedding_wrapper,
_image_generation_wrapper,
_moderation_wrapper,
_responses_wrapper,
_speech_wrapper,
_transcription_wrapper,
)

Expand DownExpand Up@@ -78,6 +80,18 @@ class LiteLLMModerationPatcher(FunctionWrapperPatcher):
wrapper = _moderation_wrapper


class LiteLLMSpeechPatcher(FunctionWrapperPatcher):
name = "litellm.speech"
target_path = "speech"
wrapper = _speech_wrapper


class LiteLLMAspeechPatcher(FunctionWrapperPatcher):
name = "litellm.aspeech"
target_path = "aspeech"
wrapper = _aspeech_wrapper_async


class LiteLLMTranscriptionPatcher(FunctionWrapperPatcher):
name = "litellm.transcription"
target_path = "transcription"
Expand All@@ -104,6 +118,8 @@ class LiteLLMATranscriptionPatcher(FunctionWrapperPatcher):
LiteLLMEmbeddingPatcher,
LiteLLMAembeddingPatcher,
LiteLLMModerationPatcher,
LiteLLMSpeechPatcher,
LiteLLMAspeechPatcher,
LiteLLMTranscriptionPatcher,
LiteLLMATranscriptionPatcher,
)
Expand All@@ -122,8 +138,8 @@ def wrap_litellm(litellm: Any) -> Any:
that exposes the same top-level callables such as ``completion``,
``acompletion``, ``responses``, ``aresponses``, ``image_generation``,
``aimage_generation``, ``embedding``, ``aembedding``, ``moderation``,
``transcription``, and ``atranscription``). Each patcher is applied
idempotently — calling
``speech``, ``aspeech``, ``transcription``, and ``atranscription``).
Each patcher is applied idempotently — calling
``wrap_litellm`` twice on the same object is safe.

Args:
Expand Down
60 changes: 60 additions & 0 deletions py/src/braintrust/integrations/litellm/test_litellm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,15 @@
TEST_AUDIO_FILE = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures", "test_audio.wav")


def _assert_speech_output_attachment(span) -> None:
assert span["output"]["type"] == "audio"
assert span["output"]["audio_size_bytes"] > 0
attachment = span["output"]["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"].startswith("audio/")
assert attachment.reference["filename"].startswith("generated_speech")


@pytest.fixture(autouse=True)
def _patch():
patch_litellm()
Expand DownExpand Up@@ -402,6 +411,57 @@ async def test_litellm_atranscription(memory_logger):
assert span["output"] == "you"


@pytest.mark.vcr
def test_litellm_speech(memory_logger):
assert not memory_logger.pop()

response = litellm.speech(
model="tts-1",
voice="alloy",
input="Hello, this is a test.",
response_format="mp3",
)

assert response
assert response.content

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == "tts-1"
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["response_format"] == "mp3"
assert span["metadata"]["provider"] == "litellm"
assert span["input"] == "Hello, this is a test."
_assert_speech_output_attachment(span)


@pytest.mark.vcr
@pytest.mark.asyncio
async def test_litellm_aspeech(memory_logger):
assert not memory_logger.pop()

response = await litellm.aspeech(
model="tts-1",
voice="alloy",
input="Hello, this is a test.",
response_format="mp3",
)

assert response
assert response.content

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == "tts-1"
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["response_format"] == "mp3"
assert span["metadata"]["provider"] == "litellm"
assert span["input"] == "Hello, this is a test."
_assert_speech_output_attachment(span)


@pytest.mark.vcr
@pytest.mark.asyncio
async def test_litellm_acompletion_with_system_prompt(memory_logger):
Expand Down
41 changes: 41 additions & 0 deletions py/src/braintrust/integrations/litellm/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@
from typing import Any

from braintrust.integrations.utils import (
_extract_audio_output,
_materialize_attachment,
_parse_openai_usage_metrics,
_prettify_response_params,
Expand DownExpand Up@@ -441,6 +442,46 @@ def _moderation_wrapper(wrapped, instance, args, kwargs):
return moderation_response


def _speech_wrapper(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.speech."""
updated_span_payload = _update_span_payload_from_params(kwargs, input_key="input")

with start_span(
**merge_dicts(dict(name="Speech", span_attributes={"type": SpanTypeAttribute.LLM}), updated_span_payload)
) as span:
start = time.time()
speech_response = wrapped(*args, **kwargs)
span.log(
metrics=_timing_metrics(start, time.time()),
output=_extract_audio_output(
speech_response,
response_format=kwargs.get("response_format"),
prefix="generated_speech",
),
)
return speech_response


async def _aspeech_wrapper_async(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.aspeech."""
updated_span_payload = _update_span_payload_from_params(kwargs, input_key="input")

with start_span(
**merge_dicts(dict(name="Speech", span_attributes={"type": SpanTypeAttribute.LLM}), updated_span_payload)
) as span:
start = time.time()
speech_response = await wrapped(*args, **kwargs)
span.log(
metrics=_timing_metrics(start, time.time()),
output=_extract_audio_output(
speech_response,
response_format=kwargs.get("response_format"),
prefix="generated_speech",
),
)
return speech_response


def _transcription_wrapper(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.transcription."""
updated_span_payload = _update_audio_span_payload_from_params(kwargs)
Expand Down
13 changes: 11 additions & 2 deletions py/src/braintrust/integrations/openai/test_openai.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1933,6 +1933,15 @@ def _assert_audio_input_attachment(span) -> None:
assert span["input"]["file"].reference["content_type"].startswith("audio/")


def _assert_audio_output_attachment(span) -> None:
assert span["output"]["type"] == "audio"
assert span["output"]["audio_size_bytes"] > 0
attachment = span["output"]["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"].startswith("audio/")
assert attachment.reference["filename"].startswith("generated_speech")


def _write_test_png(path: str, *, width: int = 64, height: int = 64) -> None:
"""Write a simple opaque red RGBA PNG without external dependencies."""

Expand DownExpand Up@@ -2067,7 +2076,7 @@ def test_openai_audio_speech(memory_logger):
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["provider"] == "openai"
assert span["input"] == "Hello, this is a test."
assert span["output"] == {"type": "audio"}
_assert_audio_output_attachment(span)


@pytest.mark.vcr
Expand DownExpand Up@@ -2175,7 +2184,7 @@ async def test_openai_audio_speech_async(memory_logger):
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["provider"] == "openai"
assert span["input"] == "Hello, this is a test."
assert span["output"] == {"type": "audio"}
_assert_audio_output_attachment(span)


@pytest.mark.asyncio
Expand Down
3 changes: 2 additions & 1 deletion py/src/braintrust/integrations/openai/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
from typing import Any

from braintrust.integrations.utils import (
_extract_audio_output,
_materialize_attachment,
_parse_openai_usage_metrics,
_prettify_response_params,
Expand DownExpand Up@@ -1276,7 +1277,7 @@ def __init__(self, create_fn: Callable[..., Any] | None, acreate_fn: Callable[..
super().__init__(create_fn, acreate_fn, "Speech")

def process_output(self, response: Any, span: Span):
span.log(output={"type": "audio"})
span.log(output=_extract_audio_output(response, prefix="generated_speech"))


class _AudioFileWrapper(BaseWrapper):
Expand Down
38 changes: 38 additions & 0 deletions py/src/braintrust/integrations/test_utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@
from braintrust.integrations.utils import (
_attachment_filename_for_mime_type,
_camel_to_snake,
_extract_audio_output,
_infer_audio_mime_type,
_is_supported_metric_value,
_log_and_end_span,
_log_error_and_end_span,
Expand DownExpand Up@@ -301,6 +303,42 @@ def test_materialize_attachment_returns_none_for_non_data_url_strings():
assert _materialize_attachment("https://example.com/image.png") is None


def test_infer_audio_mime_type_prefers_response_headers():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/mpeg; charset=binary"})
response = unittest.mock.Mock(response=raw_response)

assert _infer_audio_mime_type(response, response_format="wav") == "audio/mpeg"


def test_extract_audio_output_materializes_attachment_from_binary_response():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/mpeg"})
response = unittest.mock.Mock(content=b"audio-bytes", response=raw_response)

output = _extract_audio_output(response, prefix="generated_speech")

assert output["type"] == "audio"
assert output["mime_type"] == "audio/mpeg"
assert output["audio_size_bytes"] == len(b"audio-bytes")
attachment = output["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"] == "audio/mpeg"
assert attachment.reference["filename"] == "generated_speech.mp3"


def test_extract_audio_output_supports_mapping_with_raw_response_only():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/wav"}, content=b"wave")

output = _extract_audio_output({"response": raw_response}, prefix="generated_speech")

assert output["type"] == "audio"
assert output["mime_type"] == "audio/wav"
assert output["audio_size_bytes"] == len(b"wave")
attachment = output["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"] == "audio/wav"
assert attachment.reference["filename"] == "generated_speech.wav"


def test_serialize_response_format_with_pydantic_basemodel_subclass():
pydantic = pytest.importorskip("pydantic")

Expand Down
66 changes: 66 additions & 0 deletions py/src/braintrust/integrations/utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -343,6 +343,72 @@ def _materialize_attachment(
return None


_AUDIO_FORMAT_TO_MIME_TYPE = {
"mp3": "audio/mpeg",
"wav": "audio/wav",
"opus": "audio/opus",
"aac": "audio/aac",
"flac": "audio/flac",
"pcm": "audio/pcm",
}


def _infer_audio_mime_type(response: Any, response_format: Any = None) -> str:
raw_response = getattr(response, "response", None)
if raw_response is None and isinstance(response, Mapping):
raw_response = response.get("response")

headers = getattr(raw_response, "headers", None)
if headers is not None:
content_type = headers.get("content-type")
if isinstance(content_type, str) and content_type:
return content_type.split(";", 1)[0].strip()

if isinstance(response_format, str) and response_format:
normalized = response_format.lower()
return _AUDIO_FORMAT_TO_MIME_TYPE.get(
normalized,
normalized if "/" in normalized else f"audio/{normalized}",
)

return "application/octet-stream"


def _extract_audio_output(
response: Any,
*,
response_format: Any = None,
prefix: str = "generated_audio",
) -> dict[str, Any]:
audio_bytes = getattr(response, "content", None)
if not isinstance(audio_bytes, (bytes, bytearray)) and isinstance(response, Mapping):
raw_response = response.get("response")
audio_bytes = getattr(raw_response, "content", None)

if not isinstance(audio_bytes, (bytes, bytearray)):
return {"type": "audio"}

mime_type = _infer_audio_mime_type(response, response_format)
resolved_attachment = _materialize_attachment(
audio_bytes,
mime_type=mime_type,
prefix=prefix,
)
if resolved_attachment is None:
return {
"type": "audio",
"mime_type": mime_type,
"audio_size_bytes": len(audio_bytes),
}

return {
"type": "audio",
"mime_type": resolved_attachment.mime_type,
"audio_size_bytes": len(audio_bytes),
**resolved_attachment.multimodal_part_payload,
}


def _is_not_given(value: object) -> bool:
"""Return ``True`` when *value* is a provider ``NOT_GIVEN`` sentinel.

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(openai|litellm): attach speech outputs by AbhiPrasad · Pull Request #267 · braintrustdata/braintrust-sdk-python · GitHub
Skip to content
Merged
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
6 changes: 3 additions & 3 deletions py/src/braintrust/integrations/litellm/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,9 +10,9 @@ def patch_litellm() -> bool:
This wraps litellm.completion, litellm.acompletion, litellm.responses,
litellm.aresponses, litellm.image_generation, litellm.aimage_generation,
litellm.embedding, litellm.aembedding, litellm.moderation,
litellm.transcription, and litellm.atranscription to automatically
create Braintrust spans with detailed token metrics,
timing, and costs.
litellm.speech, litellm.aspeech, litellm.transcription, and
litellm.atranscription to automatically create Braintrust spans with
detailed token metrics, timing, and costs.

Returns:
True if LiteLLM was patched (or already patched), False if LiteLLM is not installed.
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

20 changes: 18 additions & 2 deletions py/src/braintrust/integrations/litellm/patchers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,12 +9,14 @@
_aembedding_wrapper_async,
_aimage_generation_wrapper_async,
_aresponses_wrapper_async,
_aspeech_wrapper_async,
_atranscription_wrapper_async,
_completion_wrapper,
_embedding_wrapper,
_image_generation_wrapper,
_moderation_wrapper,
_responses_wrapper,
_speech_wrapper,
_transcription_wrapper,
)

Expand DownExpand Up@@ -78,6 +80,18 @@ class LiteLLMModerationPatcher(FunctionWrapperPatcher):
wrapper = _moderation_wrapper


class LiteLLMSpeechPatcher(FunctionWrapperPatcher):
name = "litellm.speech"
target_path = "speech"
wrapper = _speech_wrapper


class LiteLLMAspeechPatcher(FunctionWrapperPatcher):
name = "litellm.aspeech"
target_path = "aspeech"
wrapper = _aspeech_wrapper_async


class LiteLLMTranscriptionPatcher(FunctionWrapperPatcher):
name = "litellm.transcription"
target_path = "transcription"
Expand All@@ -104,6 +118,8 @@ class LiteLLMATranscriptionPatcher(FunctionWrapperPatcher):
LiteLLMEmbeddingPatcher,
LiteLLMAembeddingPatcher,
LiteLLMModerationPatcher,
LiteLLMSpeechPatcher,
LiteLLMAspeechPatcher,
LiteLLMTranscriptionPatcher,
LiteLLMATranscriptionPatcher,
)
Expand All@@ -122,8 +138,8 @@ def wrap_litellm(litellm: Any) -> Any:
that exposes the same top-level callables such as ``completion``,
``acompletion``, ``responses``, ``aresponses``, ``image_generation``,
``aimage_generation``, ``embedding``, ``aembedding``, ``moderation``,
``transcription``, and ``atranscription``). Each patcher is applied
idempotently — calling
``speech``, ``aspeech``, ``transcription``, and ``atranscription``).
Each patcher is applied idempotently — calling
``wrap_litellm`` twice on the same object is safe.

Args:
Expand Down
60 changes: 60 additions & 0 deletions py/src/braintrust/integrations/litellm/test_litellm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,15 @@
TEST_AUDIO_FILE = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures", "test_audio.wav")


def _assert_speech_output_attachment(span) -> None:
assert span["output"]["type"] == "audio"
assert span["output"]["audio_size_bytes"] > 0
attachment = span["output"]["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"].startswith("audio/")
assert attachment.reference["filename"].startswith("generated_speech")


@pytest.fixture(autouse=True)
def _patch():
patch_litellm()
Expand DownExpand Up@@ -402,6 +411,57 @@ async def test_litellm_atranscription(memory_logger):
assert span["output"] == "you"


@pytest.mark.vcr
def test_litellm_speech(memory_logger):
assert not memory_logger.pop()

response = litellm.speech(
model="tts-1",
voice="alloy",
input="Hello, this is a test.",
response_format="mp3",
)

assert response
assert response.content

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == "tts-1"
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["response_format"] == "mp3"
assert span["metadata"]["provider"] == "litellm"
assert span["input"] == "Hello, this is a test."
_assert_speech_output_attachment(span)


@pytest.mark.vcr
@pytest.mark.asyncio
async def test_litellm_aspeech(memory_logger):
assert not memory_logger.pop()

response = await litellm.aspeech(
model="tts-1",
voice="alloy",
input="Hello, this is a test.",
response_format="mp3",
)

assert response
assert response.content

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == "tts-1"
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["response_format"] == "mp3"
assert span["metadata"]["provider"] == "litellm"
assert span["input"] == "Hello, this is a test."
_assert_speech_output_attachment(span)


@pytest.mark.vcr
@pytest.mark.asyncio
async def test_litellm_acompletion_with_system_prompt(memory_logger):
Expand Down
41 changes: 41 additions & 0 deletions py/src/braintrust/integrations/litellm/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@
from typing import Any

from braintrust.integrations.utils import (
_extract_audio_output,
_materialize_attachment,
_parse_openai_usage_metrics,
_prettify_response_params,
Expand DownExpand Up@@ -441,6 +442,46 @@ def _moderation_wrapper(wrapped, instance, args, kwargs):
return moderation_response


def _speech_wrapper(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.speech."""
updated_span_payload = _update_span_payload_from_params(kwargs, input_key="input")

with start_span(
**merge_dicts(dict(name="Speech", span_attributes={"type": SpanTypeAttribute.LLM}), updated_span_payload)
) as span:
start = time.time()
speech_response = wrapped(*args, **kwargs)
span.log(
metrics=_timing_metrics(start, time.time()),
output=_extract_audio_output(
speech_response,
response_format=kwargs.get("response_format"),
prefix="generated_speech",
),
)
return speech_response


async def _aspeech_wrapper_async(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.aspeech."""
updated_span_payload = _update_span_payload_from_params(kwargs, input_key="input")

with start_span(
**merge_dicts(dict(name="Speech", span_attributes={"type": SpanTypeAttribute.LLM}), updated_span_payload)
) as span:
start = time.time()
speech_response = await wrapped(*args, **kwargs)
span.log(
metrics=_timing_metrics(start, time.time()),
output=_extract_audio_output(
speech_response,
response_format=kwargs.get("response_format"),
prefix="generated_speech",
),
)
return speech_response


def _transcription_wrapper(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.transcription."""
updated_span_payload = _update_audio_span_payload_from_params(kwargs)
Expand Down
13 changes: 11 additions & 2 deletions py/src/braintrust/integrations/openai/test_openai.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1933,6 +1933,15 @@ def _assert_audio_input_attachment(span) -> None:
assert span["input"]["file"].reference["content_type"].startswith("audio/")


def _assert_audio_output_attachment(span) -> None:
assert span["output"]["type"] == "audio"
assert span["output"]["audio_size_bytes"] > 0
attachment = span["output"]["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"].startswith("audio/")
assert attachment.reference["filename"].startswith("generated_speech")


def _write_test_png(path: str, *, width: int = 64, height: int = 64) -> None:
"""Write a simple opaque red RGBA PNG without external dependencies."""

Expand DownExpand Up@@ -2067,7 +2076,7 @@ def test_openai_audio_speech(memory_logger):
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["provider"] == "openai"
assert span["input"] == "Hello, this is a test."
assert span["output"] == {"type": "audio"}
_assert_audio_output_attachment(span)


@pytest.mark.vcr
Expand DownExpand Up@@ -2175,7 +2184,7 @@ async def test_openai_audio_speech_async(memory_logger):
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["provider"] == "openai"
assert span["input"] == "Hello, this is a test."
assert span["output"] == {"type": "audio"}
_assert_audio_output_attachment(span)


@pytest.mark.asyncio
Expand Down
3 changes: 2 additions & 1 deletion py/src/braintrust/integrations/openai/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
from typing import Any

from braintrust.integrations.utils import (
_extract_audio_output,
_materialize_attachment,
_parse_openai_usage_metrics,
_prettify_response_params,
Expand DownExpand Up@@ -1276,7 +1277,7 @@ def __init__(self, create_fn: Callable[..., Any] | None, acreate_fn: Callable[..
super().__init__(create_fn, acreate_fn, "Speech")

def process_output(self, response: Any, span: Span):
span.log(output={"type": "audio"})
span.log(output=_extract_audio_output(response, prefix="generated_speech"))


class _AudioFileWrapper(BaseWrapper):
Expand Down
38 changes: 38 additions & 0 deletions py/src/braintrust/integrations/test_utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@
from braintrust.integrations.utils import (
_attachment_filename_for_mime_type,
_camel_to_snake,
_extract_audio_output,
_infer_audio_mime_type,
_is_supported_metric_value,
_log_and_end_span,
_log_error_and_end_span,
Expand DownExpand Up@@ -301,6 +303,42 @@ def test_materialize_attachment_returns_none_for_non_data_url_strings():
assert _materialize_attachment("https://example.com/image.png") is None


def test_infer_audio_mime_type_prefers_response_headers():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/mpeg; charset=binary"})
response = unittest.mock.Mock(response=raw_response)

assert _infer_audio_mime_type(response, response_format="wav") == "audio/mpeg"


def test_extract_audio_output_materializes_attachment_from_binary_response():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/mpeg"})
response = unittest.mock.Mock(content=b"audio-bytes", response=raw_response)

output = _extract_audio_output(response, prefix="generated_speech")

assert output["type"] == "audio"
assert output["mime_type"] == "audio/mpeg"
assert output["audio_size_bytes"] == len(b"audio-bytes")
attachment = output["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"] == "audio/mpeg"
assert attachment.reference["filename"] == "generated_speech.mp3"


def test_extract_audio_output_supports_mapping_with_raw_response_only():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/wav"}, content=b"wave")

output = _extract_audio_output({"response": raw_response}, prefix="generated_speech")

assert output["type"] == "audio"
assert output["mime_type"] == "audio/wav"
assert output["audio_size_bytes"] == len(b"wave")
attachment = output["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"] == "audio/wav"
assert attachment.reference["filename"] == "generated_speech.wav"


def test_serialize_response_format_with_pydantic_basemodel_subclass():
pydantic = pytest.importorskip("pydantic")

Expand Down
66 changes: 66 additions & 0 deletions py/src/braintrust/integrations/utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -343,6 +343,72 @@ def _materialize_attachment(
return None


_AUDIO_FORMAT_TO_MIME_TYPE = {
"mp3": "audio/mpeg",
"wav": "audio/wav",
"opus": "audio/opus",
"aac": "audio/aac",
"flac": "audio/flac",
"pcm": "audio/pcm",
}


def _infer_audio_mime_type(response: Any, response_format: Any = None) -> str:
raw_response = getattr(response, "response", None)
if raw_response is None and isinstance(response, Mapping):
raw_response = response.get("response")

headers = getattr(raw_response, "headers", None)
if headers is not None:
content_type = headers.get("content-type")
if isinstance(content_type, str) and content_type:
return content_type.split(";", 1)[0].strip()

if isinstance(response_format, str) and response_format:
normalized = response_format.lower()
return _AUDIO_FORMAT_TO_MIME_TYPE.get(
normalized,
normalized if "/" in normalized else f"audio/{normalized}",
)

return "application/octet-stream"


def _extract_audio_output(
response: Any,
*,
response_format: Any = None,
prefix: str = "generated_audio",
) -> dict[str, Any]:
audio_bytes = getattr(response, "content", None)
if not isinstance(audio_bytes, (bytes, bytearray)) and isinstance(response, Mapping):
raw_response = response.get("response")
audio_bytes = getattr(raw_response, "content", None)

if not isinstance(audio_bytes, (bytes, bytearray)):
return {"type": "audio"}

mime_type = _infer_audio_mime_type(response, response_format)
resolved_attachment = _materialize_attachment(
audio_bytes,
mime_type=mime_type,
prefix=prefix,
)
if resolved_attachment is None:
return {
"type": "audio",
"mime_type": mime_type,
"audio_size_bytes": len(audio_bytes),
}

return {
"type": "audio",
"mime_type": resolved_attachment.mime_type,
"audio_size_bytes": len(audio_bytes),
**resolved_attachment.multimodal_part_payload,
}


def _is_not_given(value: object) -> bool:
"""Return ``True`` when *value* is a provider ``NOT_GIVEN`` sentinel.

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(openai|litellm): attach speech outputs by AbhiPrasad · Pull Request #267 · braintrustdata/braintrust-sdk-python · GitHub
Skip to content
Merged
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
6 changes: 3 additions & 3 deletions py/src/braintrust/integrations/litellm/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,9 +10,9 @@ def patch_litellm() -> bool:
This wraps litellm.completion, litellm.acompletion, litellm.responses,
litellm.aresponses, litellm.image_generation, litellm.aimage_generation,
litellm.embedding, litellm.aembedding, litellm.moderation,
litellm.transcription, and litellm.atranscription to automatically
create Braintrust spans with detailed token metrics,
timing, and costs.
litellm.speech, litellm.aspeech, litellm.transcription, and
litellm.atranscription to automatically create Braintrust spans with
detailed token metrics, timing, and costs.

Returns:
True if LiteLLM was patched (or already patched), False if LiteLLM is not installed.
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

20 changes: 18 additions & 2 deletions py/src/braintrust/integrations/litellm/patchers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,12 +9,14 @@
_aembedding_wrapper_async,
_aimage_generation_wrapper_async,
_aresponses_wrapper_async,
_aspeech_wrapper_async,
_atranscription_wrapper_async,
_completion_wrapper,
_embedding_wrapper,
_image_generation_wrapper,
_moderation_wrapper,
_responses_wrapper,
_speech_wrapper,
_transcription_wrapper,
)

Expand DownExpand Up@@ -78,6 +80,18 @@ class LiteLLMModerationPatcher(FunctionWrapperPatcher):
wrapper = _moderation_wrapper


class LiteLLMSpeechPatcher(FunctionWrapperPatcher):
name = "litellm.speech"
target_path = "speech"
wrapper = _speech_wrapper


class LiteLLMAspeechPatcher(FunctionWrapperPatcher):
name = "litellm.aspeech"
target_path = "aspeech"
wrapper = _aspeech_wrapper_async


class LiteLLMTranscriptionPatcher(FunctionWrapperPatcher):
name = "litellm.transcription"
target_path = "transcription"
Expand All@@ -104,6 +118,8 @@ class LiteLLMATranscriptionPatcher(FunctionWrapperPatcher):
LiteLLMEmbeddingPatcher,
LiteLLMAembeddingPatcher,
LiteLLMModerationPatcher,
LiteLLMSpeechPatcher,
LiteLLMAspeechPatcher,
LiteLLMTranscriptionPatcher,
LiteLLMATranscriptionPatcher,
)
Expand All@@ -122,8 +138,8 @@ def wrap_litellm(litellm: Any) -> Any:
that exposes the same top-level callables such as ``completion``,
``acompletion``, ``responses``, ``aresponses``, ``image_generation``,
``aimage_generation``, ``embedding``, ``aembedding``, ``moderation``,
``transcription``, and ``atranscription``). Each patcher is applied
idempotently — calling
``speech``, ``aspeech``, ``transcription``, and ``atranscription``).
Each patcher is applied idempotently — calling
``wrap_litellm`` twice on the same object is safe.

Args:
Expand Down
60 changes: 60 additions & 0 deletions py/src/braintrust/integrations/litellm/test_litellm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,15 @@
TEST_AUDIO_FILE = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures", "test_audio.wav")


def _assert_speech_output_attachment(span) -> None:
assert span["output"]["type"] == "audio"
assert span["output"]["audio_size_bytes"] > 0
attachment = span["output"]["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"].startswith("audio/")
assert attachment.reference["filename"].startswith("generated_speech")


@pytest.fixture(autouse=True)
def _patch():
patch_litellm()
Expand DownExpand Up@@ -402,6 +411,57 @@ async def test_litellm_atranscription(memory_logger):
assert span["output"] == "you"


@pytest.mark.vcr
def test_litellm_speech(memory_logger):
assert not memory_logger.pop()

response = litellm.speech(
model="tts-1",
voice="alloy",
input="Hello, this is a test.",
response_format="mp3",
)

assert response
assert response.content

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == "tts-1"
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["response_format"] == "mp3"
assert span["metadata"]["provider"] == "litellm"
assert span["input"] == "Hello, this is a test."
_assert_speech_output_attachment(span)


@pytest.mark.vcr
@pytest.mark.asyncio
async def test_litellm_aspeech(memory_logger):
assert not memory_logger.pop()

response = await litellm.aspeech(
model="tts-1",
voice="alloy",
input="Hello, this is a test.",
response_format="mp3",
)

assert response
assert response.content

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == "tts-1"
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["response_format"] == "mp3"
assert span["metadata"]["provider"] == "litellm"
assert span["input"] == "Hello, this is a test."
_assert_speech_output_attachment(span)


@pytest.mark.vcr
@pytest.mark.asyncio
async def test_litellm_acompletion_with_system_prompt(memory_logger):
Expand Down
41 changes: 41 additions & 0 deletions py/src/braintrust/integrations/litellm/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@
from typing import Any

from braintrust.integrations.utils import (
_extract_audio_output,
_materialize_attachment,
_parse_openai_usage_metrics,
_prettify_response_params,
Expand DownExpand Up@@ -441,6 +442,46 @@ def _moderation_wrapper(wrapped, instance, args, kwargs):
return moderation_response


def _speech_wrapper(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.speech."""
updated_span_payload = _update_span_payload_from_params(kwargs, input_key="input")

with start_span(
**merge_dicts(dict(name="Speech", span_attributes={"type": SpanTypeAttribute.LLM}), updated_span_payload)
) as span:
start = time.time()
speech_response = wrapped(*args, **kwargs)
span.log(
metrics=_timing_metrics(start, time.time()),
output=_extract_audio_output(
speech_response,
response_format=kwargs.get("response_format"),
prefix="generated_speech",
),
)
return speech_response


async def _aspeech_wrapper_async(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.aspeech."""
updated_span_payload = _update_span_payload_from_params(kwargs, input_key="input")

with start_span(
**merge_dicts(dict(name="Speech", span_attributes={"type": SpanTypeAttribute.LLM}), updated_span_payload)
) as span:
start = time.time()
speech_response = await wrapped(*args, **kwargs)
span.log(
metrics=_timing_metrics(start, time.time()),
output=_extract_audio_output(
speech_response,
response_format=kwargs.get("response_format"),
prefix="generated_speech",
),
)
return speech_response


def _transcription_wrapper(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.transcription."""
updated_span_payload = _update_audio_span_payload_from_params(kwargs)
Expand Down
13 changes: 11 additions & 2 deletions py/src/braintrust/integrations/openai/test_openai.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1933,6 +1933,15 @@ def _assert_audio_input_attachment(span) -> None:
assert span["input"]["file"].reference["content_type"].startswith("audio/")


def _assert_audio_output_attachment(span) -> None:
assert span["output"]["type"] == "audio"
assert span["output"]["audio_size_bytes"] > 0
attachment = span["output"]["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"].startswith("audio/")
assert attachment.reference["filename"].startswith("generated_speech")


def _write_test_png(path: str, *, width: int = 64, height: int = 64) -> None:
"""Write a simple opaque red RGBA PNG without external dependencies."""

Expand DownExpand Up@@ -2067,7 +2076,7 @@ def test_openai_audio_speech(memory_logger):
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["provider"] == "openai"
assert span["input"] == "Hello, this is a test."
assert span["output"] == {"type": "audio"}
_assert_audio_output_attachment(span)


@pytest.mark.vcr
Expand DownExpand Up@@ -2175,7 +2184,7 @@ async def test_openai_audio_speech_async(memory_logger):
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["provider"] == "openai"
assert span["input"] == "Hello, this is a test."
assert span["output"] == {"type": "audio"}
_assert_audio_output_attachment(span)


@pytest.mark.asyncio
Expand Down
3 changes: 2 additions & 1 deletion py/src/braintrust/integrations/openai/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
from typing import Any

from braintrust.integrations.utils import (
_extract_audio_output,
_materialize_attachment,
_parse_openai_usage_metrics,
_prettify_response_params,
Expand DownExpand Up@@ -1276,7 +1277,7 @@ def __init__(self, create_fn: Callable[..., Any] | None, acreate_fn: Callable[..
super().__init__(create_fn, acreate_fn, "Speech")

def process_output(self, response: Any, span: Span):
span.log(output={"type": "audio"})
span.log(output=_extract_audio_output(response, prefix="generated_speech"))


class _AudioFileWrapper(BaseWrapper):
Expand Down
38 changes: 38 additions & 0 deletions py/src/braintrust/integrations/test_utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@
from braintrust.integrations.utils import (
_attachment_filename_for_mime_type,
_camel_to_snake,
_extract_audio_output,
_infer_audio_mime_type,
_is_supported_metric_value,
_log_and_end_span,
_log_error_and_end_span,
Expand DownExpand Up@@ -301,6 +303,42 @@ def test_materialize_attachment_returns_none_for_non_data_url_strings():
assert _materialize_attachment("https://example.com/image.png") is None


def test_infer_audio_mime_type_prefers_response_headers():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/mpeg; charset=binary"})
response = unittest.mock.Mock(response=raw_response)

assert _infer_audio_mime_type(response, response_format="wav") == "audio/mpeg"


def test_extract_audio_output_materializes_attachment_from_binary_response():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/mpeg"})
response = unittest.mock.Mock(content=b"audio-bytes", response=raw_response)

output = _extract_audio_output(response, prefix="generated_speech")

assert output["type"] == "audio"
assert output["mime_type"] == "audio/mpeg"
assert output["audio_size_bytes"] == len(b"audio-bytes")
attachment = output["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"] == "audio/mpeg"
assert attachment.reference["filename"] == "generated_speech.mp3"


def test_extract_audio_output_supports_mapping_with_raw_response_only():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/wav"}, content=b"wave")

output = _extract_audio_output({"response": raw_response}, prefix="generated_speech")

assert output["type"] == "audio"
assert output["mime_type"] == "audio/wav"
assert output["audio_size_bytes"] == len(b"wave")
attachment = output["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"] == "audio/wav"
assert attachment.reference["filename"] == "generated_speech.wav"


def test_serialize_response_format_with_pydantic_basemodel_subclass():
pydantic = pytest.importorskip("pydantic")

Expand Down
66 changes: 66 additions & 0 deletions py/src/braintrust/integrations/utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -343,6 +343,72 @@ def _materialize_attachment(
return None


_AUDIO_FORMAT_TO_MIME_TYPE = {
"mp3": "audio/mpeg",
"wav": "audio/wav",
"opus": "audio/opus",
"aac": "audio/aac",
"flac": "audio/flac",
"pcm": "audio/pcm",
}


def _infer_audio_mime_type(response: Any, response_format: Any = None) -> str:
raw_response = getattr(response, "response", None)
if raw_response is None and isinstance(response, Mapping):
raw_response = response.get("response")

headers = getattr(raw_response, "headers", None)
if headers is not None:
content_type = headers.get("content-type")
if isinstance(content_type, str) and content_type:
return content_type.split(";", 1)[0].strip()

if isinstance(response_format, str) and response_format:
normalized = response_format.lower()
return _AUDIO_FORMAT_TO_MIME_TYPE.get(
normalized,
normalized if "/" in normalized else f"audio/{normalized}",
)

return "application/octet-stream"


def _extract_audio_output(
response: Any,
*,
response_format: Any = None,
prefix: str = "generated_audio",
) -> dict[str, Any]:
audio_bytes = getattr(response, "content", None)
if not isinstance(audio_bytes, (bytes, bytearray)) and isinstance(response, Mapping):
raw_response = response.get("response")
audio_bytes = getattr(raw_response, "content", None)

if not isinstance(audio_bytes, (bytes, bytearray)):
return {"type": "audio"}

mime_type = _infer_audio_mime_type(response, response_format)
resolved_attachment = _materialize_attachment(
audio_bytes,
mime_type=mime_type,
prefix=prefix,
)
if resolved_attachment is None:
return {
"type": "audio",
"mime_type": mime_type,
"audio_size_bytes": len(audio_bytes),
}

return {
"type": "audio",
"mime_type": resolved_attachment.mime_type,
"audio_size_bytes": len(audio_bytes),
**resolved_attachment.multimodal_part_payload,
}


def _is_not_given(value: object) -> bool:
"""Return ``True`` when *value* is a provider ``NOT_GIVEN`` sentinel.

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(openai|litellm): attach speech outputs by AbhiPrasad · Pull Request #267 · braintrustdata/braintrust-sdk-python · GitHub
Skip to content
Merged
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
6 changes: 3 additions & 3 deletions py/src/braintrust/integrations/litellm/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,9 +10,9 @@ def patch_litellm() -> bool:
This wraps litellm.completion, litellm.acompletion, litellm.responses,
litellm.aresponses, litellm.image_generation, litellm.aimage_generation,
litellm.embedding, litellm.aembedding, litellm.moderation,
litellm.transcription, and litellm.atranscription to automatically
create Braintrust spans with detailed token metrics,
timing, and costs.
litellm.speech, litellm.aspeech, litellm.transcription, and
litellm.atranscription to automatically create Braintrust spans with
detailed token metrics, timing, and costs.

Returns:
True if LiteLLM was patched (or already patched), False if LiteLLM is not installed.
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

20 changes: 18 additions & 2 deletions py/src/braintrust/integrations/litellm/patchers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,12 +9,14 @@
_aembedding_wrapper_async,
_aimage_generation_wrapper_async,
_aresponses_wrapper_async,
_aspeech_wrapper_async,
_atranscription_wrapper_async,
_completion_wrapper,
_embedding_wrapper,
_image_generation_wrapper,
_moderation_wrapper,
_responses_wrapper,
_speech_wrapper,
_transcription_wrapper,
)

Expand DownExpand Up@@ -78,6 +80,18 @@ class LiteLLMModerationPatcher(FunctionWrapperPatcher):
wrapper = _moderation_wrapper


class LiteLLMSpeechPatcher(FunctionWrapperPatcher):
name = "litellm.speech"
target_path = "speech"
wrapper = _speech_wrapper


class LiteLLMAspeechPatcher(FunctionWrapperPatcher):
name = "litellm.aspeech"
target_path = "aspeech"
wrapper = _aspeech_wrapper_async


class LiteLLMTranscriptionPatcher(FunctionWrapperPatcher):
name = "litellm.transcription"
target_path = "transcription"
Expand All@@ -104,6 +118,8 @@ class LiteLLMATranscriptionPatcher(FunctionWrapperPatcher):
LiteLLMEmbeddingPatcher,
LiteLLMAembeddingPatcher,
LiteLLMModerationPatcher,
LiteLLMSpeechPatcher,
LiteLLMAspeechPatcher,
LiteLLMTranscriptionPatcher,
LiteLLMATranscriptionPatcher,
)
Expand All@@ -122,8 +138,8 @@ def wrap_litellm(litellm: Any) -> Any:
that exposes the same top-level callables such as ``completion``,
``acompletion``, ``responses``, ``aresponses``, ``image_generation``,
``aimage_generation``, ``embedding``, ``aembedding``, ``moderation``,
``transcription``, and ``atranscription``). Each patcher is applied
idempotently — calling
``speech``, ``aspeech``, ``transcription``, and ``atranscription``).
Each patcher is applied idempotently — calling
``wrap_litellm`` twice on the same object is safe.

Args:
Expand Down
60 changes: 60 additions & 0 deletions py/src/braintrust/integrations/litellm/test_litellm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,15 @@
TEST_AUDIO_FILE = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures", "test_audio.wav")


def _assert_speech_output_attachment(span) -> None:
assert span["output"]["type"] == "audio"
assert span["output"]["audio_size_bytes"] > 0
attachment = span["output"]["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"].startswith("audio/")
assert attachment.reference["filename"].startswith("generated_speech")


@pytest.fixture(autouse=True)
def _patch():
patch_litellm()
Expand DownExpand Up@@ -402,6 +411,57 @@ async def test_litellm_atranscription(memory_logger):
assert span["output"] == "you"


@pytest.mark.vcr
def test_litellm_speech(memory_logger):
assert not memory_logger.pop()

response = litellm.speech(
model="tts-1",
voice="alloy",
input="Hello, this is a test.",
response_format="mp3",
)

assert response
assert response.content

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == "tts-1"
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["response_format"] == "mp3"
assert span["metadata"]["provider"] == "litellm"
assert span["input"] == "Hello, this is a test."
_assert_speech_output_attachment(span)


@pytest.mark.vcr
@pytest.mark.asyncio
async def test_litellm_aspeech(memory_logger):
assert not memory_logger.pop()

response = await litellm.aspeech(
model="tts-1",
voice="alloy",
input="Hello, this is a test.",
response_format="mp3",
)

assert response
assert response.content

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == "tts-1"
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["response_format"] == "mp3"
assert span["metadata"]["provider"] == "litellm"
assert span["input"] == "Hello, this is a test."
_assert_speech_output_attachment(span)


@pytest.mark.vcr
@pytest.mark.asyncio
async def test_litellm_acompletion_with_system_prompt(memory_logger):
Expand Down
41 changes: 41 additions & 0 deletions py/src/braintrust/integrations/litellm/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@
from typing import Any

from braintrust.integrations.utils import (
_extract_audio_output,
_materialize_attachment,
_parse_openai_usage_metrics,
_prettify_response_params,
Expand DownExpand Up@@ -441,6 +442,46 @@ def _moderation_wrapper(wrapped, instance, args, kwargs):
return moderation_response


def _speech_wrapper(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.speech."""
updated_span_payload = _update_span_payload_from_params(kwargs, input_key="input")

with start_span(
**merge_dicts(dict(name="Speech", span_attributes={"type": SpanTypeAttribute.LLM}), updated_span_payload)
) as span:
start = time.time()
speech_response = wrapped(*args, **kwargs)
span.log(
metrics=_timing_metrics(start, time.time()),
output=_extract_audio_output(
speech_response,
response_format=kwargs.get("response_format"),
prefix="generated_speech",
),
)
return speech_response


async def _aspeech_wrapper_async(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.aspeech."""
updated_span_payload = _update_span_payload_from_params(kwargs, input_key="input")

with start_span(
**merge_dicts(dict(name="Speech", span_attributes={"type": SpanTypeAttribute.LLM}), updated_span_payload)
) as span:
start = time.time()
speech_response = await wrapped(*args, **kwargs)
span.log(
metrics=_timing_metrics(start, time.time()),
output=_extract_audio_output(
speech_response,
response_format=kwargs.get("response_format"),
prefix="generated_speech",
),
)
return speech_response


def _transcription_wrapper(wrapped, instance, args, kwargs):
"""wrapt wrapper for litellm.transcription."""
updated_span_payload = _update_audio_span_payload_from_params(kwargs)
Expand Down
13 changes: 11 additions & 2 deletions py/src/braintrust/integrations/openai/test_openai.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1933,6 +1933,15 @@ def _assert_audio_input_attachment(span) -> None:
assert span["input"]["file"].reference["content_type"].startswith("audio/")


def _assert_audio_output_attachment(span) -> None:
assert span["output"]["type"] == "audio"
assert span["output"]["audio_size_bytes"] > 0
attachment = span["output"]["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"].startswith("audio/")
assert attachment.reference["filename"].startswith("generated_speech")


def _write_test_png(path: str, *, width: int = 64, height: int = 64) -> None:
"""Write a simple opaque red RGBA PNG without external dependencies."""

Expand DownExpand Up@@ -2067,7 +2076,7 @@ def test_openai_audio_speech(memory_logger):
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["provider"] == "openai"
assert span["input"] == "Hello, this is a test."
assert span["output"] == {"type": "audio"}
_assert_audio_output_attachment(span)


@pytest.mark.vcr
Expand DownExpand Up@@ -2175,7 +2184,7 @@ async def test_openai_audio_speech_async(memory_logger):
assert span["metadata"]["voice"] == "alloy"
assert span["metadata"]["provider"] == "openai"
assert span["input"] == "Hello, this is a test."
assert span["output"] == {"type": "audio"}
_assert_audio_output_attachment(span)


@pytest.mark.asyncio
Expand Down
3 changes: 2 additions & 1 deletion py/src/braintrust/integrations/openai/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
from typing import Any

from braintrust.integrations.utils import (
_extract_audio_output,
_materialize_attachment,
_parse_openai_usage_metrics,
_prettify_response_params,
Expand DownExpand Up@@ -1276,7 +1277,7 @@ def __init__(self, create_fn: Callable[..., Any] | None, acreate_fn: Callable[..
super().__init__(create_fn, acreate_fn, "Speech")

def process_output(self, response: Any, span: Span):
span.log(output={"type": "audio"})
span.log(output=_extract_audio_output(response, prefix="generated_speech"))


class _AudioFileWrapper(BaseWrapper):
Expand Down
38 changes: 38 additions & 0 deletions py/src/braintrust/integrations/test_utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@
from braintrust.integrations.utils import (
_attachment_filename_for_mime_type,
_camel_to_snake,
_extract_audio_output,
_infer_audio_mime_type,
_is_supported_metric_value,
_log_and_end_span,
_log_error_and_end_span,
Expand DownExpand Up@@ -301,6 +303,42 @@ def test_materialize_attachment_returns_none_for_non_data_url_strings():
assert _materialize_attachment("https://example.com/image.png") is None


def test_infer_audio_mime_type_prefers_response_headers():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/mpeg; charset=binary"})
response = unittest.mock.Mock(response=raw_response)

assert _infer_audio_mime_type(response, response_format="wav") == "audio/mpeg"


def test_extract_audio_output_materializes_attachment_from_binary_response():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/mpeg"})
response = unittest.mock.Mock(content=b"audio-bytes", response=raw_response)

output = _extract_audio_output(response, prefix="generated_speech")

assert output["type"] == "audio"
assert output["mime_type"] == "audio/mpeg"
assert output["audio_size_bytes"] == len(b"audio-bytes")
attachment = output["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"] == "audio/mpeg"
assert attachment.reference["filename"] == "generated_speech.mp3"


def test_extract_audio_output_supports_mapping_with_raw_response_only():
raw_response = unittest.mock.Mock(headers={"content-type": "audio/wav"}, content=b"wave")

output = _extract_audio_output({"response": raw_response}, prefix="generated_speech")

assert output["type"] == "audio"
assert output["mime_type"] == "audio/wav"
assert output["audio_size_bytes"] == len(b"wave")
attachment = output["file"]["file_data"]
assert isinstance(attachment, Attachment)
assert attachment.reference["content_type"] == "audio/wav"
assert attachment.reference["filename"] == "generated_speech.wav"


def test_serialize_response_format_with_pydantic_basemodel_subclass():
pydantic = pytest.importorskip("pydantic")

Expand Down
66 changes: 66 additions & 0 deletions py/src/braintrust/integrations/utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -343,6 +343,72 @@ def _materialize_attachment(
return None


_AUDIO_FORMAT_TO_MIME_TYPE = {
"mp3": "audio/mpeg",
"wav": "audio/wav",
"opus": "audio/opus",
"aac": "audio/aac",
"flac": "audio/flac",
"pcm": "audio/pcm",
}


def _infer_audio_mime_type(response: Any, response_format: Any = None) -> str:
raw_response = getattr(response, "response", None)
if raw_response is None and isinstance(response, Mapping):
raw_response = response.get("response")

headers = getattr(raw_response, "headers", None)
if headers is not None:
content_type = headers.get("content-type")
if isinstance(content_type, str) and content_type:
return content_type.split(";", 1)[0].strip()

if isinstance(response_format, str) and response_format:
normalized = response_format.lower()
return _AUDIO_FORMAT_TO_MIME_TYPE.get(
normalized,
normalized if "/" in normalized else f"audio/{normalized}",
)

return "application/octet-stream"


def _extract_audio_output(
response: Any,
*,
response_format: Any = None,
prefix: str = "generated_audio",
) -> dict[str, Any]:
audio_bytes = getattr(response, "content", None)
if not isinstance(audio_bytes, (bytes, bytearray)) and isinstance(response, Mapping):
raw_response = response.get("response")
audio_bytes = getattr(raw_response, "content", None)

if not isinstance(audio_bytes, (bytes, bytearray)):
return {"type": "audio"}

mime_type = _infer_audio_mime_type(response, response_format)
resolved_attachment = _materialize_attachment(
audio_bytes,
mime_type=mime_type,
prefix=prefix,
)
if resolved_attachment is None:
return {
"type": "audio",
"mime_type": mime_type,
"audio_size_bytes": len(audio_bytes),
}

return {
"type": "audio",
"mime_type": resolved_attachment.mime_type,
"audio_size_bytes": len(audio_bytes),
**resolved_attachment.multimodal_part_payload,
}


def _is_not_given(value: object) -> bool:
"""Return ``True`` when *value* is a provider ``NOT_GIVEN`` sentinel.

Expand Down
Loading