diff --git a/examples/listen/14-transcription-live-websocket-v2.py b/examples/listen/14-transcription-live-websocket-v2.py new file mode 100644 index 00000000..fdd6f670 --- /dev/null +++ b/examples/listen/14-transcription-live-websocket-v2.py @@ -0,0 +1,76 @@ +""" +Example: Live Transcription with WebSocket V2 (Listen V2) + +This example shows how to use Listen V2 for advanced conversational speech recognition +with contextual turn detection. +""" + +import os +import threading +import time +from pathlib import Path +from typing import Union + +from dotenv import load_dotenv + +load_dotenv() + +from deepgram import DeepgramClient +from deepgram.core.events import EventType +from deepgram.extensions.types.sockets import ( + ListenV2ConnectedEvent, + ListenV2ControlMessage, + ListenV2FatalErrorEvent, + ListenV2TurnInfoEvent, +) + +ListenV2SocketClientResponse = Union[ListenV2ConnectedEvent, ListenV2TurnInfoEvent, ListenV2FatalErrorEvent] + +client = DeepgramClient(api_key=os.environ.get("DEEPGRAM_API_KEY")) + +try: + with client.listen.v2.connect( + model="flux-general-en", + encoding="linear16", + sample_rate="16000", + ) as connection: + + def on_message(message: ListenV2SocketClientResponse) -> None: + msg_type = getattr(message, "type", type(message).__name__) + print(f"Received {msg_type} event ({type(message).__name__})") + + # Extract transcription from TurnInfo events + if isinstance(message, ListenV2TurnInfoEvent): + print(f" transcript: {message.transcript}") + print(f" event: {message.event}") + print(f" turn_index: {message.turn_index}") + + connection.on(EventType.OPEN, lambda _: print("Connection opened")) + connection.on(EventType.MESSAGE, on_message) + connection.on(EventType.CLOSE, lambda _: print("Connection closed")) + connection.on(EventType.ERROR, lambda error: print(f"Error: {type(error).__name__}: {error}")) + + # Send audio in a background thread so start_listening can process responses + def send_audio(): + audio_path = Path(__file__).parent.parent / "fixtures" / "audio.wav" + with open(audio_path, "rb") as f: + audio = f.read() + + # Send in chunks + chunk_size = 4096 + for i in range(0, len(audio), chunk_size): + connection.send_media(audio[i : i + chunk_size]) + time.sleep(0.01) # pace the sending + + # Signal end of audio + time.sleep(2) + connection.send_control(ListenV2ControlMessage(type="CloseStream")) + + sender = threading.Thread(target=send_audio, daemon=True) + sender.start() + + # This blocks until the connection closes + connection.start_listening() + +except Exception as e: + print(f"Error: {type(e).__name__}: {e}") diff --git a/src/deepgram/agent/v1/socket_client.py b/src/deepgram/agent/v1/socket_client.py index f76fc9e4..83ad43a0 100644 --- a/src/deepgram/agent/v1/socket_client.py +++ b/src/deepgram/agent/v1/socket_client.py @@ -8,7 +8,7 @@ import websockets import websockets.sync.connection as websockets_sync_connection from ...core.events import EventEmitterMixin, EventType -from ...core.pydantic_utilities import parse_obj_as +from ...core.unchecked_base_model import construct_type try: from websockets.legacy.client import WebSocketClientProtocol # type: ignore @@ -84,7 +84,7 @@ def _handle_binary_message(self, message: bytes) -> typing.Any: def _handle_json_message(self, message: str) -> typing.Any: """Handle a JSON message by parsing it.""" json_data = json.loads(message) - return parse_obj_as(V1SocketClientResponse, json_data) # type: ignore + return construct_type(type_=V1SocketClientResponse, object_=json_data) # type: ignore def _process_message(self, raw_message: typing.Any) -> typing.Tuple[typing.Any, bool]: """Process a raw message, detecting if it's binary or JSON.""" @@ -199,7 +199,7 @@ def _handle_binary_message(self, message: bytes) -> typing.Any: def _handle_json_message(self, message: str) -> typing.Any: """Handle a JSON message by parsing it.""" json_data = json.loads(message) - return parse_obj_as(V1SocketClientResponse, json_data) # type: ignore + return construct_type(type_=V1SocketClientResponse, object_=json_data) # type: ignore def _process_message(self, raw_message: typing.Any) -> typing.Tuple[typing.Any, bool]: """Process a raw message, detecting if it's binary or JSON.""" diff --git a/src/deepgram/core/__init__.py b/src/deepgram/core/__init__.py index 2668e033..4fb9d908 100644 --- a/src/deepgram/core/__init__.py +++ b/src/deepgram/core/__init__.py @@ -27,6 +27,7 @@ from .remove_none_from_dict import remove_none_from_dict from .request_options import RequestOptions from .serialization import FieldMetadata, convert_and_respect_annotation_metadata + from .unchecked_base_model import UncheckedBaseModel, UnionMetadata, construct_type _dynamic_imports: typing.Dict[str, str] = { "ApiError": ".api_error", "AsyncClientWrapper": ".client_wrapper", @@ -44,6 +45,9 @@ "SyncClientWrapper": ".client_wrapper", "UniversalBaseModel": ".pydantic_utilities", "UniversalRootModel": ".pydantic_utilities", + "UncheckedBaseModel": ".unchecked_base_model", + "UnionMetadata": ".unchecked_base_model", + "construct_type": ".unchecked_base_model", "convert_and_respect_annotation_metadata": ".serialization", "convert_file_dict_to_httpx_tuples": ".file", "encode_query": ".query_encoder", @@ -94,8 +98,11 @@ def __dir__(): "IS_PYDANTIC_V2", "RequestOptions", "SyncClientWrapper", + "UncheckedBaseModel", + "UnionMetadata", "UniversalBaseModel", "UniversalRootModel", + "construct_type", "convert_and_respect_annotation_metadata", "convert_file_dict_to_httpx_tuples", "encode_query", diff --git a/src/deepgram/core/unchecked_base_model.py b/src/deepgram/core/unchecked_base_model.py new file mode 100644 index 00000000..9ea71ca6 --- /dev/null +++ b/src/deepgram/core/unchecked_base_model.py @@ -0,0 +1,376 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import inspect +import typing +import uuid + +import pydantic +import typing_extensions +from .pydantic_utilities import ( + IS_PYDANTIC_V2, + ModelField, + UniversalBaseModel, + get_args, + get_origin, + is_literal_type, + is_union, + parse_date, + parse_datetime, + parse_obj_as, +) +from .serialization import get_field_to_alias_mapping +from pydantic_core import PydanticUndefined + + +class UnionMetadata: + discriminant: str + + def __init__(self, *, discriminant: str) -> None: + self.discriminant = discriminant + + +Model = typing.TypeVar("Model", bound=pydantic.BaseModel) + + +class UncheckedBaseModel(UniversalBaseModel): + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow") # type: ignore # Pydantic v2 + else: + + class Config: + extra = pydantic.Extra.allow + + @classmethod + def model_construct( + cls: typing.Type["Model"], + _fields_set: typing.Optional[typing.Set[str]] = None, + **values: typing.Any, + ) -> "Model": + # Fallback construct function to the specified override below. + return cls.construct(_fields_set=_fields_set, **values) + + # Allow construct to not validate model + # Implementation taken from: https://github.com/pydantic/pydantic/issues/1168#issuecomment-817742836 + @classmethod + def construct( + cls: typing.Type["Model"], + _fields_set: typing.Optional[typing.Set[str]] = None, + **values: typing.Any, + ) -> "Model": + m = cls.__new__(cls) + fields_values = {} + + if _fields_set is None: + _fields_set = set(values.keys()) + + fields = _get_model_fields(cls) + populate_by_name = _get_is_populate_by_name(cls) + field_aliases = get_field_to_alias_mapping(cls) + + for name, field in fields.items(): + # Key here is only used to pull data from the values dict + # you should always use the NAME of the field to for field_values, etc. + # because that's how the object is constructed from a pydantic perspective + key = field.alias + if (key is None or field.alias == name) and name in field_aliases: + key = field_aliases[name] + + if key is None or (key not in values and populate_by_name): # Added this to allow population by field name + key = name + + if key in values: + if IS_PYDANTIC_V2: + type_ = field.annotation # type: ignore # Pydantic v2 + else: + type_ = typing.cast(typing.Type, field.outer_type_) # type: ignore # Pydantic < v1.10.15 + + fields_values[name] = ( + construct_type(object_=values[key], type_=type_) if type_ is not None else values[key] + ) + _fields_set.add(name) + else: + default = _get_field_default(field) + fields_values[name] = default + + # If the default values are non-null act like they've been set + # This effectively allows exclude_unset to work like exclude_none where + # the latter passes through intentionally set none values. + if default != None and default != PydanticUndefined: + _fields_set.add(name) + + # Add extras back in + extras = {} + pydantic_alias_fields = [field.alias for field in fields.values()] + internal_alias_fields = list(field_aliases.values()) + for key, value in values.items(): + # If the key is not a field by name, nor an alias to a field, then it's extra + if (key not in pydantic_alias_fields and key not in internal_alias_fields) and key not in fields: + if IS_PYDANTIC_V2: + extras[key] = value + else: + _fields_set.add(key) + fields_values[key] = value + + object.__setattr__(m, "__dict__", fields_values) + + if IS_PYDANTIC_V2: + object.__setattr__(m, "__pydantic_private__", None) + object.__setattr__(m, "__pydantic_extra__", extras) + object.__setattr__(m, "__pydantic_fields_set__", _fields_set) + else: + object.__setattr__(m, "__fields_set__", _fields_set) + m._init_private_attributes() # type: ignore # Pydantic v1 + return m + + +def _validate_collection_items_compatible(collection: typing.Any, target_type: typing.Type[typing.Any]) -> bool: + """ + Validate that all items in a collection are compatible with the target type. + + Args: + collection: The collection to validate (list, set, or dict values) + target_type: The target type to validate against + + Returns: + True if all items are compatible, False otherwise + """ + if inspect.isclass(target_type) and issubclass(target_type, pydantic.BaseModel): + for item in collection: + try: + # Try to validate the item against the target type + if isinstance(item, dict): + parse_obj_as(target_type, item) + else: + # If it's not a dict, it might already be the right type + if not isinstance(item, target_type): + return False + except Exception: + return False + return True + + +def _convert_undiscriminated_union_type(union_type: typing.Type[typing.Any], object_: typing.Any) -> typing.Any: + inner_types = get_args(union_type) + if typing.Any in inner_types: + return object_ + + for inner_type in inner_types: + # Handle lists of objects that need parsing + if get_origin(inner_type) is list and isinstance(object_, list): + list_inner_type = get_args(inner_type)[0] + try: + if inspect.isclass(list_inner_type) and issubclass(list_inner_type, pydantic.BaseModel): + # Validate that all items in the list are compatible with the target type + if _validate_collection_items_compatible(object_, list_inner_type): + parsed_list = [parse_obj_as(object_=item, type_=list_inner_type) for item in object_] + return parsed_list + except Exception: + pass + + try: + if inspect.isclass(inner_type) and issubclass(inner_type, pydantic.BaseModel): + # Attempt a validated parse until one works + return parse_obj_as(inner_type, object_) + except Exception: + continue + + # If none of the types work, try matching literal fields first, then fall back + # First pass: try types where all literal fields match the object's values + for inner_type in inner_types: + if inspect.isclass(inner_type) and issubclass(inner_type, pydantic.BaseModel): + fields = _get_model_fields(inner_type) + literal_fields_match = True + + for field_name, field in fields.items(): + # Check if this field has a Literal type + if IS_PYDANTIC_V2: + field_type = field.annotation # type: ignore # Pydantic v2 + else: + field_type = field.outer_type_ # type: ignore # Pydantic v1 + + if is_literal_type(field_type): # type: ignore[arg-type] + field_default = _get_field_default(field) + name_or_alias = get_field_to_alias_mapping(inner_type).get(field_name, field_name) + # Get the value from the object + if isinstance(object_, dict): + object_value = object_.get(name_or_alias) + else: + object_value = getattr(object_, name_or_alias, None) + + # If the literal field value doesn't match, this type is not a match + if object_value is not None and field_default != object_value: + literal_fields_match = False + break + + # If all literal fields match, try to construct this type + if literal_fields_match: + try: + return construct_type(object_=object_, type_=inner_type) + except Exception: + continue + + # Second pass: if no literal matches, just return the first successful cast + for inner_type in inner_types: + try: + return construct_type(object_=object_, type_=inner_type) + except Exception: + continue + + +def _convert_union_type(type_: typing.Type[typing.Any], object_: typing.Any) -> typing.Any: + base_type = get_origin(type_) or type_ + union_type = type_ + if base_type == typing_extensions.Annotated: # type: ignore[comparison-overlap] + union_type = get_args(type_)[0] + annotated_metadata = get_args(type_)[1:] + for metadata in annotated_metadata: + if isinstance(metadata, UnionMetadata): + try: + # Cast to the correct type, based on the discriminant + for inner_type in get_args(union_type): + try: + objects_discriminant = getattr(object_, metadata.discriminant) + except: + objects_discriminant = object_[metadata.discriminant] + if inner_type.__fields__[metadata.discriminant].default == objects_discriminant: + return construct_type(object_=object_, type_=inner_type) + except Exception: + # Allow to fall through to our regular union handling + pass + return _convert_undiscriminated_union_type(union_type, object_) + + +def construct_type(*, type_: typing.Type[typing.Any], object_: typing.Any) -> typing.Any: + """ + Here we are essentially creating the same `construct` method in spirit as the above, but for all types, not just + Pydantic models. + The idea is to essentially attempt to coerce object_ to type_ (recursively) + """ + # Short circuit when dealing with optionals, don't try to coerces None to a type + if object_ is None: + return None + + base_type = get_origin(type_) or type_ + is_annotated = base_type == typing_extensions.Annotated # type: ignore[comparison-overlap] + maybe_annotation_members = get_args(type_) + is_annotated_union = is_annotated and is_union(get_origin(maybe_annotation_members[0])) + + if base_type == typing.Any: # type: ignore[comparison-overlap] + return object_ + + if base_type == dict: + if not isinstance(object_, typing.Mapping): + return object_ + + key_type, items_type = get_args(type_) + d = { + construct_type(object_=key, type_=key_type): construct_type(object_=item, type_=items_type) + for key, item in object_.items() + } + return d + + if base_type == list: + if not isinstance(object_, list): + return object_ + + inner_type = get_args(type_)[0] + return [construct_type(object_=entry, type_=inner_type) for entry in object_] + + if base_type == set: + if not isinstance(object_, set) and not isinstance(object_, list): + return object_ + + inner_type = get_args(type_)[0] + return {construct_type(object_=entry, type_=inner_type) for entry in object_} + + if is_union(base_type) or is_annotated_union: + return _convert_union_type(type_, object_) + + # Cannot do an `issubclass` with a literal type, let's also just confirm we have a class before this call + if ( + object_ is not None + and not is_literal_type(type_) + and ( + (inspect.isclass(base_type) and issubclass(base_type, pydantic.BaseModel)) + or ( + is_annotated + and inspect.isclass(maybe_annotation_members[0]) + and issubclass(maybe_annotation_members[0], pydantic.BaseModel) + ) + ) + ): + if IS_PYDANTIC_V2: + return type_.model_construct(**object_) + else: + return type_.construct(**object_) + + if base_type == dt.datetime: + try: + return parse_datetime(object_) + except Exception: + return object_ + + if base_type == dt.date: + try: + return parse_date(object_) + except Exception: + return object_ + + if base_type == uuid.UUID: + try: + return uuid.UUID(object_) + except Exception: + return object_ + + if base_type == int: + try: + return int(object_) + except Exception: + return object_ + + if base_type == bool: + try: + if isinstance(object_, str): + stringified_object = object_.lower() + return stringified_object == "true" or stringified_object == "1" + + return bool(object_) + except Exception: + return object_ + + return object_ + + +def _get_is_populate_by_name(model: typing.Type["Model"]) -> bool: + if IS_PYDANTIC_V2: + return model.model_config.get("populate_by_name", False) # type: ignore # Pydantic v2 + return model.__config__.allow_population_by_field_name # type: ignore # Pydantic v1 + + +PydanticField = typing.Union[ModelField, pydantic.fields.FieldInfo] + + +# Pydantic V1 swapped the typing of __fields__'s values from ModelField to FieldInfo +# And so we try to handle both V1 cases, as well as V2 (FieldInfo from model.model_fields) +def _get_model_fields( + model: typing.Type["Model"], +) -> typing.Mapping[str, PydanticField]: + if IS_PYDANTIC_V2: + return model.model_fields # type: ignore # Pydantic v2 + else: + return model.__fields__ # type: ignore # Pydantic v1 + + +def _get_field_default(field: PydanticField) -> typing.Any: + try: + value = field.get_default() # type: ignore # Pydantic < v1.10.15 + except: + value = field.default + if IS_PYDANTIC_V2: + from pydantic_core import PydanticUndefined + + if value == PydanticUndefined: + return None + return value + return value diff --git a/src/deepgram/listen/v1/socket_client.py b/src/deepgram/listen/v1/socket_client.py index 10ea9759..4841614f 100644 --- a/src/deepgram/listen/v1/socket_client.py +++ b/src/deepgram/listen/v1/socket_client.py @@ -8,7 +8,7 @@ import websockets import websockets.sync.connection as websockets_sync_connection from ...core.events import EventEmitterMixin, EventType -from ...core.pydantic_utilities import parse_obj_as +from ...core.unchecked_base_model import construct_type try: from websockets.legacy.client import WebSocketClientProtocol # type: ignore @@ -50,7 +50,7 @@ def _handle_binary_message(self, message: bytes) -> typing.Any: def _handle_json_message(self, message: str) -> typing.Any: """Handle a JSON message by parsing it.""" json_data = json.loads(message) - return parse_obj_as(V1SocketClientResponse, json_data) # type: ignore + return construct_type(type_=V1SocketClientResponse, object_=json_data) # type: ignore def _process_message(self, raw_message: typing.Any) -> typing.Tuple[typing.Any, bool]: """Process a raw message, detecting if it's binary or JSON.""" @@ -140,7 +140,7 @@ def _handle_binary_message(self, message: bytes) -> typing.Any: def _handle_json_message(self, message: str) -> typing.Any: """Handle a JSON message by parsing it.""" json_data = json.loads(message) - return parse_obj_as(V1SocketClientResponse, json_data) # type: ignore + return construct_type(type_=V1SocketClientResponse, object_=json_data) # type: ignore def _process_message(self, raw_message: typing.Any) -> typing.Tuple[typing.Any, bool]: """Process a raw message, detecting if it's binary or JSON.""" diff --git a/src/deepgram/listen/v2/socket_client.py b/src/deepgram/listen/v2/socket_client.py index ded23989..07b95547 100644 --- a/src/deepgram/listen/v2/socket_client.py +++ b/src/deepgram/listen/v2/socket_client.py @@ -8,7 +8,7 @@ import websockets import websockets.sync.connection as websockets_sync_connection from ...core.events import EventEmitterMixin, EventType -from ...core.pydantic_utilities import parse_obj_as +from ...core.unchecked_base_model import construct_type try: from websockets.legacy.client import WebSocketClientProtocol # type: ignore @@ -48,7 +48,7 @@ def _handle_binary_message(self, message: bytes) -> typing.Any: def _handle_json_message(self, message: str) -> typing.Any: """Handle a JSON message by parsing it.""" json_data = json.loads(message) - return parse_obj_as(V2SocketClientResponse, json_data) # type: ignore + return construct_type(type_=V2SocketClientResponse, object_=json_data) # type: ignore def _process_message(self, raw_message: typing.Any) -> typing.Tuple[typing.Any, bool]: """Process a raw message, detecting if it's binary or JSON.""" @@ -138,7 +138,7 @@ def _handle_binary_message(self, message: bytes) -> typing.Any: def _handle_json_message(self, message: str) -> typing.Any: """Handle a JSON message by parsing it.""" json_data = json.loads(message) - return parse_obj_as(V2SocketClientResponse, json_data) # type: ignore + return construct_type(type_=V2SocketClientResponse, object_=json_data) # type: ignore def _process_message(self, raw_message: typing.Any) -> typing.Tuple[typing.Any, bool]: """Process a raw message, detecting if it's binary or JSON.""" diff --git a/src/deepgram/speak/v1/socket_client.py b/src/deepgram/speak/v1/socket_client.py index 6d4b77aa..9ae79ac4 100644 --- a/src/deepgram/speak/v1/socket_client.py +++ b/src/deepgram/speak/v1/socket_client.py @@ -8,7 +8,7 @@ import websockets import websockets.sync.connection as websockets_sync_connection from ...core.events import EventEmitterMixin, EventType -from ...core.pydantic_utilities import parse_obj_as +from ...core.unchecked_base_model import construct_type try: from websockets.legacy.client import WebSocketClientProtocol # type: ignore @@ -51,7 +51,7 @@ def _handle_binary_message(self, message: bytes) -> typing.Any: def _handle_json_message(self, message: str) -> typing.Any: """Handle a JSON message by parsing it.""" json_data = json.loads(message) - return parse_obj_as(V1SocketClientResponse, json_data) # type: ignore + return construct_type(type_=V1SocketClientResponse, object_=json_data) # type: ignore def _process_message(self, raw_message: typing.Any) -> typing.Tuple[typing.Any, bool]: """Process a raw message, detecting if it's binary or JSON.""" @@ -142,7 +142,7 @@ def _handle_binary_message(self, message: bytes) -> typing.Any: def _handle_json_message(self, message: str) -> typing.Any: """Handle a JSON message by parsing it.""" json_data = json.loads(message) - return parse_obj_as(V1SocketClientResponse, json_data) # type: ignore + return construct_type(type_=V1SocketClientResponse, object_=json_data) # type: ignore def _process_message(self, raw_message: typing.Any) -> typing.Tuple[typing.Any, bool]: """Process a raw message, detecting if it's binary or JSON."""