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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ uv add scope3ai
|-------------|-----------------|----|-----|------------------|-----------|
| Anthropic | ✅ | | | | |
| Cohere | ✅ | | | | |
| OpenAI | ✅ | ✅ | ✅ | ✅ | |
| OpenAI | ✅ | ✅ | ✅ | ✅ | |
| Huggingface | ✅ | ✅ | ✅ | ✅ | ✅ |
| LiteLLM | ✅ | | | | |
| MistralAi | ✅ | | | | |
Expand Down
14 changes: 14 additions & 0 deletions scope3ai/tracers/openai/instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
openai_async_speech_to_text_wrapper,
openai_speech_to_text_wrapper,
)
from .translation import (
openai_translation_wrapper,
openai_async_translation_wrapper,
)


class OpenAIInstrumentor:
Expand Down Expand Up @@ -48,6 +52,16 @@ def __init__(self) -> None:
"name": "AsyncTranscriptions.create",
"wrapper": openai_async_speech_to_text_wrapper,
},
{
"module": "openai.resources.audio.translations",
"name": "Translations.create",
"wrapper": openai_translation_wrapper,
},
{
"module": "openai.resources.audio.translations",
"name": "AsyncTranslations.create",
"wrapper": openai_async_translation_wrapper,
},
{
"module": "openai.resources.images",
"name": "Images.create_variation",
Expand Down
31 changes: 5 additions & 26 deletions scope3ai/tracers/openai/speech_to_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,19 @@
import time
from typing import Any, Callable, Optional, Union

import openai
import tiktoken
from openai.resources.audio.transcriptions import (
AsyncTranscriptions,
Transcriptions,
)
from openai.resources.audio.transcriptions import (
Transcription as _Transcription,
)
from openai.resources.audio.transcriptions import (
from openai.resources.audio.transcriptions import AsyncTranscriptions, Transcriptions
from openai.types.audio.transcription import Transcription as _Transcription
from openai.types.audio.transcription_verbose import (
TranscriptionVerbose as _TranscriptionVerbose,
)

from scope3ai.api.types import ImpactRow, Model, Scope3AIContext, Task
from scope3ai.constants import PROVIDERS
from scope3ai.lib import Scope3AI

from .utils import _get_file_audio_duration

PROVIDER = PROVIDERS.OPENAI.value

logger = logging.getLogger("scope3.tracers.openai.speech_to_text")
Expand All @@ -36,23 +32,6 @@ class TranscriptionVerbose(_TranscriptionVerbose):
scope3ai: Optional[Scope3AIContext] = None


def _get_file_audio_duration(
file: openai._types.FileTypes,
) -> Optional[float]:
try:
from mutagen import File

if isinstance(file, (list, tuple)):
file = file[1]

audio = File(file)
if audio is not None and audio.info is not None:
return audio.info.length
except Exception as e:
logger.exception(f"Failed to get audio duration: {e}")
return None


def _openai_speech_to_text_wrapper(
response: Any, request_latency: float, kwargs: dict
) -> Union[Transcription, TranscriptionVerbose, str]:
Expand Down
91 changes: 91 additions & 0 deletions scope3ai/tracers/openai/translation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import logging
import time
from typing import Any, Callable, Optional, Union

import tiktoken
from openai.resources.audio.translations import AsyncTranslations, Translations
from openai.types.audio.translation import Translation as _Translation
from openai.types.audio.translation_verbose import (
TranslationVerbose as _TranslationVerbose,
)

from scope3ai.api.types import ImpactRow, Model, Scope3AIContext, Task
from scope3ai.constants import PROVIDERS
from scope3ai.lib import Scope3AI

from .utils import _get_file_audio_duration

PROVIDER = PROVIDERS.OPENAI.value

logger = logging.getLogger(__name__)


class AnnotatedStr(str):
scope3ai: Optional[Scope3AIContext] = None


class Translation(_Translation):
scope3ai: Optional[Scope3AIContext] = None


class TranslationVerbose(_TranslationVerbose):
scope3ai: Optional[Scope3AIContext] = None


def _openai_translation_wrapper(
response: Any, request_latency: float, kwargs: dict
) -> Union[Translation, TranslationVerbose, AnnotatedStr]:
model = kwargs["model"]
encoder = tiktoken.get_encoding("cl100k_base")

if isinstance(response, (_Translation, _TranslationVerbose)):
output_tokens = len(encoder.encode(response.text))
elif isinstance(response, str):
output_tokens = len(encoder.encode(response))
else:
output_tokens = None

options = {}
duration = _get_file_audio_duration(kwargs["file"])
if duration is not None:
options["input_audio_seconds"] = int(duration)

scope3_row = ImpactRow(
model=Model(id=model),
provider=PROVIDER,
output_tokens=output_tokens,
request_duration_ms=request_latency,
task=Task.translation,
**options,
)
scope3_ctx = Scope3AI.get_instance().submit_impact(scope3_row)

if isinstance(response, _Translation):
result = Translation.model_construct(**response.model_dump())
elif isinstance(response, _TranslationVerbose):
result = TranslationVerbose.model_construct(**response.model_dump())
elif isinstance(response, str):
result = AnnotatedStr(str)
else:
logger.error(f"Unexpected response type: {type(response)}")
return response
result.scope3ai = scope3_ctx
return result


def openai_translation_wrapper(
wrapped: Callable, instance: Translations, args: Any, kwargs: Any
) -> Union[Translation, TranslationVerbose, AnnotatedStr]:
timer_start = time.perf_counter()
response = wrapped(*args, **kwargs)
request_latency = (time.perf_counter() - timer_start) * 1000
return _openai_translation_wrapper(response, request_latency, kwargs)


async def openai_async_translation_wrapper(
wrapped: Callable, instance: AsyncTranslations, args: Any, kwargs: Any
) -> Union[Translation, TranslationVerbose, AnnotatedStr]:
timer_start = time.perf_counter()
response = await wrapped(*args, **kwargs)
request_latency = (time.perf_counter() - timer_start) * 1000
return _openai_translation_wrapper(response, request_latency, kwargs)
23 changes: 23 additions & 0 deletions scope3ai/tracers/openai/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import logging
from typing import Optional

import openai

logger = logging.getLogger("scope3.tracers.openai")


def _get_file_audio_duration(
file: openai._types.FileTypes,
) -> Optional[float]:
try:
from mutagen import File

if isinstance(file, (list, tuple)):
file = file[1]

audio = File(file)
if audio is not None and audio.info is not None:
return audio.info.length
except Exception as e:
logger.exception(f"Failed to get audio duration: {e}")
return None
Loading
Loading