From 7deb302ff2439fe6b5e58804de5da6fe5aa2e26f Mon Sep 17 00:00:00 2001 From: Luke Oliff Date: Tue, 24 Feb 2026 14:07:06 +0000 Subject: [PATCH 1/4] fix(websockets): tolerate unknown message types from API (#671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Backport of the v6.0.1 `construct_type` fix to the v5 maintenance branch - Adds `unchecked_base_model.py` (from v6) with `construct_type` which does best-effort union coercion instead of strict Pydantic validation - Switches all 4 socket clients (listen v1, listen v2, speak v1, agent v1) from `parse_obj_as` to `construct_type` - Unknown WebSocket message types (e.g. `ConfigureSuccess`) now pass through without crashing the listener ## Test plan - [x] 853 unit/integration tests pass - [x] Verified against live Deepgram API with Listen V2 (Flux) — `ConfigureSuccess` unknown message type coerced cleanly as `ListenV2ConnectedEvent` - [x] Verify no regressions in existing v5 consumers --- .../14-transcription-live-websocket-v2.py | 76 ++++ src/deepgram/agent/v1/socket_client.py | 6 +- src/deepgram/core/__init__.py | 7 + src/deepgram/core/unchecked_base_model.py | 376 ++++++++++++++++++ src/deepgram/listen/v1/socket_client.py | 6 +- src/deepgram/listen/v2/socket_client.py | 6 +- src/deepgram/speak/v1/socket_client.py | 6 +- 7 files changed, 471 insertions(+), 12 deletions(-) create mode 100644 examples/listen/14-transcription-live-websocket-v2.py create mode 100644 src/deepgram/core/unchecked_base_model.py 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.""" From 0378dfd50f16dcd7391ffc8d22f9295ffc53bb04 Mon Sep 17 00:00:00 2001 From: Luke Oliff Date: Tue, 24 Feb 2026 14:15:21 +0000 Subject: [PATCH 2/4] chore(release): release 5.3.3 --- .github/.release-please-manifest.json | 2 +- .github/workflows/maintenance-release.yml | 71 +++++++++++++++++++++++ CHANGELOG.md | 7 +++ pyproject.toml | 2 +- src/deepgram/core/client_wrapper.py | 2 +- 5 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/maintenance-release.yml diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index 0ceacd89..0e4c66cd 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "5.3.2" + ".": "5.3.3" } \ No newline at end of file diff --git a/.github/workflows/maintenance-release.yml b/.github/workflows/maintenance-release.yml new file mode 100644 index 00000000..85923e81 --- /dev/null +++ b/.github/workflows/maintenance-release.yml @@ -0,0 +1,71 @@ +name: Maintenance Release + +on: + push: + tags: + - "v5.*" + +permissions: + contents: write + +jobs: + compile: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - name: Checkout repo + uses: actions/checkout@v4 + - name: Set up python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Bootstrap poetry + run: | + curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 + - name: Install dependencies + run: poetry install + - name: Compile + run: poetry run mypy src/ + test: + runs-on: ubuntu-latest + needs: compile + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - name: Checkout repo + uses: actions/checkout@v4 + - name: Set up python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Bootstrap poetry + run: | + curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 + - name: Install dependencies + run: poetry install + - name: Test + run: poetry run pytest -rP . + publish: + runs-on: ubuntu-latest + needs: test + steps: + - name: Checkout repo + uses: actions/checkout@v4 + - name: Set up python + uses: actions/setup-python@v6 + with: + python-version: "3.8" + - name: Bootstrap poetry + run: | + curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 + - name: Install dependencies + run: poetry install + - name: Build package + run: poetry build + - name: Publish to PyPI + env: + POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} + run: poetry publish diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d249665..dac7e081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [5.3.3](https://github.com/deepgram/deepgram-python-sdk/compare/v5.3.2...v5.3.3) (2026-02-24) + + +### Bug Fixes + +* **websockets:** tolerate unknown message types from API ([#671](https://github.com/deepgram/deepgram-python-sdk/issues/671)) ([7deb302](https://github.com/deepgram/deepgram-python-sdk/commit/7deb302)) + ## [5.3.2](https://github.com/deepgram/deepgram-python-sdk/compare/v5.3.0...v5.3.2) (2026-01-29) diff --git a/pyproject.toml b/pyproject.toml index 4fa06a02..bc5e53d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ dynamic = ["version"] [tool.poetry] name = "deepgram-sdk" -version = "5.3.2" +version = "5.3.3" description = "" readme = "README.md" authors = [] diff --git a/src/deepgram/core/client_wrapper.py b/src/deepgram/core/client_wrapper.py index e1f36bb0..dd3194cb 100644 --- a/src/deepgram/core/client_wrapper.py +++ b/src/deepgram/core/client_wrapper.py @@ -26,7 +26,7 @@ def get_headers(self) -> typing.Dict[str, str]: "X-Fern-Language": "Python", "X-Fern-SDK-Name": "deepgram", # x-release-please-start-version - "X-Fern-SDK-Version": "5.3.2", + "X-Fern-SDK-Version": "5.3.3", # x-release-please-end **(self.get_custom_headers() or {}), } From be07aa69fc68e382ddef8aed42968753031ad50c Mon Sep 17 00:00:00 2001 From: andreyas1337 Date: Fri, 26 Jun 2026 18:07:07 +0200 Subject: [PATCH 3/4] fix(listen/v2): allow extra fields on TurnInfo models (5.3.x) (#736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The v2 listen (Flux) `TurnInfo` message can include additional **per-word fields `start` and `end`** (type `double`) beyond the `word`/`confidence` the 5.3.x models declare. This was observed intermittently against the live API (roughly 1 in 6 connections in testing); I have not confirmed the full server-side rollout scope. The model `ListenV2TurnInfoEventWordsItem` declares only `word` + `confidence` with `extra = "forbid"`, so any frame carrying extra fields fails pydantic validation in `listen/v2/socket_client.py` (`parse_obj_as(V2SocketClientResponse, ...)`). Observed impact on the released 5.3.x line: - **5.3.0**: uncaught `ValidationError` propagates out of `start_listening()` (which only catches `WebSocketException` / `JSONDecodeError`) and tears down the listen loop; it never reaches the `EventType.ERROR` handler. - **5.3.2 / 5.3.3**: `construct_type` doesn't raise, but the strict word model still fails the union's validated pass, so the frame is silently mis-resolved to `ListenV2ConnectedEvent` — transcript and words dropped, no error (silent data loss). ## Fix Relax `extra` from `"forbid"` to `"allow"` on `ListenV2TurnInfoEventWordsItem` and `ListenV2TurnInfoEvent`, so unknown additive fields are tolerated and retained. Model-agnostic — it doesn't assume which model or which fields. Verified: a frame with `start`/`end` then parses as `ListenV2TurnInfoEvent` with the fields preserved. ```diff class Config: frozen = True - extra = "forbid" + extra = "allow" ``` (applied to both models in the file) ## Safety / side-effect analysis - **No code-execution risk.** Pydantic `extra="allow"` only stores unknown JSON values as data attributes (`__pydantic_extra__`); it does not eval/import/instantiate types. Not pickle/YAML-style gadget deserialization. Inbound data also comes from the Deepgram API over TLS, not arbitrary user input. - **Union resolution stays correct (tested).** `V2SocketClientResponse = Union[Connected, TurnInfo, FatalError]`. `TurnInfo` has required fields that `Connected`/`FatalError` lack, and those two keep `extra="forbid"`, so a permissive `TurnInfo` cannot greedily capture them. Verified: `Connected` → `ListenV2ConnectedEvent`, `Error` → `ListenV2FatalErrorEvent`, `TurnInfo+start/end` → `ListenV2TurnInfoEvent`. - **Required-field validation preserved (tested).** A `TurnInfo` frame missing a required field (e.g. `transcript`) still raises `ValidationError`. Only *unknown additive* fields are now tolerated. - **Memory:** negligible — `json.loads` already materializes the full payload before pydantic runs; `allow` just retains references (bounded by message size). - **Tradeoff:** loses the loud failure on unexpected additive fields (a contract-drift signal) — but that brittleness is exactly the bug, and this matches `main`'s posture. ### `allow` vs `ignore` - **`allow`** (chosen): tolerates **and exposes** the new fields, so callers can read `word.start` / `word.end`. Matches `main` (6.x/7.x). - **`ignore`**: also fixes the crash/mis-typing but **discards** extras — more conservative (nothing unvalidated retained or re-serialized), at the cost of the timestamps being unavailable. Happy to switch to `ignore` if the team prefers minimal surface. ## Notes for reviewers - `main` (6.x/7.x) already ships `extra="allow"` on the equivalent model, so this only affects the released **5.3.x** line — hence targeting `v5`. - This file is **Fern-generated** (`# auto-generated by Fern from our API Definition`); if `v5` is regenerated the proper fix belongs in the API definition. This is a direct patch on the frozen maintenance branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- .../extensions/types/sockets/listen_v2_turn_info_event.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/deepgram/extensions/types/sockets/listen_v2_turn_info_event.py b/src/deepgram/extensions/types/sockets/listen_v2_turn_info_event.py index ab099dd9..80eb5033 100644 --- a/src/deepgram/extensions/types/sockets/listen_v2_turn_info_event.py +++ b/src/deepgram/extensions/types/sockets/listen_v2_turn_info_event.py @@ -19,7 +19,7 @@ def dict(self, **kwargs) -> dict: class Config: frozen = True - extra = "forbid" + extra = "allow" class ListenV2TurnInfoEvent(UniversalBaseModel): @@ -44,4 +44,4 @@ def dict(self, **kwargs) -> dict: class Config: frozen = True - extra = "forbid" + extra = "allow" From 24b1e02679c0d708db9cce8d3f92249ec5a1092f Mon Sep 17 00:00:00 2001 From: dg-coreylweathers Date: Fri, 26 Jun 2026 12:11:00 -0400 Subject: [PATCH 4/4] chore(release): release 5.3.4 (#737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintenance release of the 5.3.x line. Bumps the version `5.3.3` → `5.3.4` (manifest, `pyproject.toml`, `client_wrapper.py` SDK header) and adds the CHANGELOG entry for #736. ### Included - #736 — fix(listen/v2): allow extra fields on Flux `TurnInfo` models (`extra="forbid"` → `"allow"`), preventing the crash (5.3.0) / silent transcript drop (5.3.2/5.3.3) when Flux emits per-word `start`/`end`. ### Release mechanism After this merges, pushing tag `v5.3.4` triggers `.github/workflows/maintenance-release.yml`, which compiles, tests, builds, and publishes to PyPI. A GitHub Release will be created with `--latest=false` so `7.3.1` keeps the repo "Latest" badge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Corey Weathers Co-authored-by: Claude Opus 4.8 (1M context) --- .github/.release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/deepgram/core/client_wrapper.py | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index 0e4c66cd..5ec8f831 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "5.3.3" + ".": "5.3.4" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index dac7e081..2aa4a7b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [5.3.4](https://github.com/deepgram/deepgram-python-sdk/compare/v5.3.3...v5.3.4) (2026-06-26) + + +### Bug Fixes + +* **listen/v2:** allow extra fields on Flux TurnInfo models ([#736](https://github.com/deepgram/deepgram-python-sdk/issues/736)) ([be07aa6](https://github.com/deepgram/deepgram-python-sdk/commit/be07aa6)) + ## [5.3.3](https://github.com/deepgram/deepgram-python-sdk/compare/v5.3.2...v5.3.3) (2026-02-24) diff --git a/pyproject.toml b/pyproject.toml index bc5e53d3..3a979f90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ dynamic = ["version"] [tool.poetry] name = "deepgram-sdk" -version = "5.3.3" +version = "5.3.4" description = "" readme = "README.md" authors = [] diff --git a/src/deepgram/core/client_wrapper.py b/src/deepgram/core/client_wrapper.py index dd3194cb..c8a7b0e0 100644 --- a/src/deepgram/core/client_wrapper.py +++ b/src/deepgram/core/client_wrapper.py @@ -26,7 +26,7 @@ def get_headers(self) -> typing.Dict[str, str]: "X-Fern-Language": "Python", "X-Fern-SDK-Name": "deepgram", # x-release-please-start-version - "X-Fern-SDK-Version": "5.3.3", + "X-Fern-SDK-Version": "5.3.4", # x-release-please-end **(self.get_custom_headers() or {}), }