diff --git a/.fernignore b/.fernignore index 3c9cd00d..6fda5042 100644 --- a/.fernignore +++ b/.fernignore @@ -14,13 +14,18 @@ src/deepgram/client.py # key / access token) in DEBUG handshake logs. No Fern-generated counterpart. src/deepgram/_secure_logging.py +# Hand-written read-side compatibility base for Listen V2 response models. +# Provides read-only wire-key subscript access during the SDK 7.x +# transition from raw dict responses to typed models. No Fern counterpart. +src/deepgram/listen/v2/types/_dict_compat.py + # WebSocket socket clients: # - except Exception broad catch (supports custom transports, generator narrows to WebSocketException) # - _sanitize_numeric_types in agent socket client (float→int for API) # - optional message param on control send_ methods (send_keep_alive, send_close_stream, etc.) # so users don't need to instantiate the type themselves for no-payload control messages -# - listen/v2 send_configure: typing.Any / raw _send shim (generator's ListenV2Configure model -# and ListenV2ConfigureSuccess not used) +# - listen/v2 send_configure: runtime tolerance for a raw dict alongside the +# generated ListenV2Configure model # [temporarily frozen — manual patches listed above] src/deepgram/agent/v1/socket_client.py src/deepgram/listen/v1/socket_client.py @@ -146,6 +151,19 @@ src/deepgram/core/parse_error.py src/deepgram/core/query_encoder.py +# Read-side compatibility for the SDK 7.7 Listen V2 response retype. These +# generated response models inherit the hand-written base above so callers can +# temporarily use both attribute and read-only subscript access. Remove the custom +# inheritance and unfreeze these files in the next major release. +# [temporarily frozen -- manual patch described above] +src/deepgram/listen/v2/types/listen_v2connected.py +src/deepgram/listen/v2/types/listen_v2turn_info.py +src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py +src/deepgram/listen/v2/types/listen_v2configure_success.py +src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py +src/deepgram/listen/v2/types/listen_v2configure_failure.py +src/deepgram/listen/v2/types/listen_v2fatal_error.py + # Hand-written custom tests tests/custom/test_api_error_redaction.py tests/custom/test_agent_history.py @@ -164,6 +182,7 @@ tests/custom/test_latency_report_stt_compat.py tests/custom/test_listen_v2_connect_wire.py tests/custom/test_listen_v2_regen_constraints.py tests/custom/test_logging_and_retry_branches.py +tests/custom/test_model_dict_compat.py tests/custom/test_query_encoder.py tests/custom/test_secure_logging.py tests/custom/test_socket_client_shims.py diff --git a/AGENTS.md b/AGENTS.md index 807310be..e274f8dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ How to identify: Current permanently frozen files: - `src/deepgram/client.py` — entirely custom (Bearer auth, session ID, `transport_factory`, `reconnect` parity flag); no Fern equivalent - `src/deepgram/_secure_logging.py` — hand-written security utility that installs a `logging.Filter` on the `websockets` client/server loggers to mask the `Authorization` header in DEBUG handshake logs; called from `client.py`; no Fern equivalent +- `src/deepgram/listen/v2/types/_dict_compat.py` — hand-written read-side compatibility base for Listen V2 response models. It provides read-only wire-key subscript access during the SDK 7.x transition from raw dict responses to typed models; no Fern counterpart. Remove in the next major release after restoring the temporarily frozen response models below to inherit `UncheckedBaseModel` directly. - `src/deepgram/helpers/` — hand-written TextBuilder helpers - `src/deepgram/agent/v1/types/agent_v1history_content.py`, `src/deepgram/agent/v1/types/agent_v1history_function_calls.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item_content.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item_content_role.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item_function_calls.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item_function_calls_function_calls_item.py` — hand-written compatibility aliases preserving old public Agent History type imports after regen renames - `src/deepgram/agent/v1/requests/agent_v1history_content.py`, `src/deepgram/agent/v1/requests/agent_v1history_function_calls.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context_messages_item.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context_messages_item_content.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context_messages_item_function_calls.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context_messages_item_function_calls_function_calls_item.py` — hand-written compatibility aliases preserving old public Agent History request-param imports after regen renames @@ -34,6 +35,7 @@ Current permanently frozen files: - `src/deepgram/transport_interface.py`, `src/deepgram/transport.py`, `src/deepgram/transports/` — custom transport layer - `tests/custom/test_agent_history.py` — hand-written regression test for Agent History websocket payload parsing - `tests/custom/test_compat_aliases.py` — hand-written regression test for backward-compatible alias imports after regen renames +- `tests/custom/test_model_dict_compat.py` — hand-written regression coverage for the read-only subscript compatibility shim on typed Listen V2 response models, including sync/async parsing, every response variant, nested objects, unknown fields, omitted-key behavior, and scope isolation from unrelated generated models - `tests/custom/test_query_encoder.py` — hand-written regression test that `core/query_encoder.py` coerces Python bools to lowercase `"true"`/`"false"` before `urlencode` so websocket query strings stay wire-correct - `tests/custom/test_secure_logging.py` — hand-written regression test that the `websockets` Authorization-header DEBUG logs are redacted (API key never logged in clear text) - `tests/custom/test_speak_v2_interrupt_configure.py` — hand-written coverage for the Speak V2 barge-in / mid-stream reconfigure surface (`send_interrupt`, `send_configure`, `SpeechInterrupted`, `ConfigureSuccess`/`ConfigureFailure`, and the `speed`/`expressivity` connect params) @@ -63,6 +65,7 @@ Current temporarily frozen files: - `src/deepgram/agent/v1/types/agent_v1settings_agent_context.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent.py`, `src/deepgram/agent/v1/types/agent_v1settings.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent.py`, `src/deepgram/agent/v1/requests/agent_v1settings.py` — backward-compat patches for the 2026-05-05 Agent Settings schema restructure. These preserve callable `AgentV1SettingsAgent(...)`, keep `AgentV1Settings.agent` accepting both that wrapper and `agent_id` strings, restore the legacy request TypedDict shapes, remap legacy `messages=[...]` / nested `context=AgentV1SettingsAgentContext(messages=[...])` usage into the new `context={"messages": [...]}` wire shape, and keep read-side `obj.messages` access working. - `src/deepgram/core/api_error.py`, `src/deepgram/core/parse_error.py` — credential redaction. Every websocket `connect()` path raises `ApiError(headers=dict(headers), ...)` with the full request headers, and both error types stringify that dict, so an unredacted `Authorization` reached `str(e)`, tracebacks, log aggregators and error trackers (which serialise attributes as well as the message). Both now mask credential values at construction via `_secure_logging.redact_sensitive_headers`, preserving non-sensitive headers (`dg-request-id`) for debugging. This is the same threat `_secure_logging.py` covers for the `websockets` DEBUG handshake logs, via the other path to it. Regression coverage in `tests/custom/test_api_error_redaction.py`. Unfreeze if the generator starts redacting credentials itself. - `src/deepgram/core/query_encoder.py` — coerces Python bools to lowercase `"true"`/`"false"` before they reach `urllib.parse.urlencode` (which would otherwise produce `"True"`/`"False"` via `str()` and break websocket query strings). Only the four `*/connect()` paths call `urlencode`; HTTP raw clients hand params to httpx, which lowercases bools itself, so the patch is a no-op for the HTTP path. Once Fern's websocket codegen normalizes bools (or the spec types these as `boolean` end-to-end), this can be unfrozen. +- `src/deepgram/listen/v2/types/listen_v2connected.py`, `src/deepgram/listen/v2/types/listen_v2turn_info.py`, `src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py`, `src/deepgram/listen/v2/types/listen_v2configure_success.py`, `src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py`, `src/deepgram/listen/v2/types/listen_v2configure_failure.py`, `src/deepgram/listen/v2/types/listen_v2fatal_error.py` — read-side compatibility for the SDK 7.7 Listen V2 response retype. Through 7.6, `V2SocketClientResponse` contained `typing.Any`, so every response was returned as a raw dict; fixing the union made responses typed models and broke callers using `response["field"]`. These generated response classes inherit the hand-written base above, preserving read-only wire-key subscript access alongside canonical attribute access. Restore direct `UncheckedBaseModel` inheritance and unfreeze these files in the next major release. - `src/deepgram/types/deepgram_listen_provider_v2.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_listen_provider.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_listen_provider.py` — behavioural back-compat shim for the `language_hint` -> `language_hints` rename (2026-06-15 regen). The public field was historically (incorrectly) singular and accepted a str or a list; the API field is `language_hints` (a list, and the server uses `deny_unknown_fields` so the singular key is rejected on the wire). Each carries a hand-added `model_validator(mode='before')` / `root_validator(pre=True)` that remaps a legacy `language_hint=` kwarg and drops the dead singular key. Remove and unfreeze when the singular alias is retired in a future major. - `src/deepgram/agent/v1/types/agent_v1update_listen_listen.py` — backward-compat patch for the 2026-07-31 `AgentV1UpdateListen` provider retype. The `provider` field changed from a bare `DeepgramListenProviderV2` to the required discriminated union `AgentV1UpdateListenListenProvider` (`_V1`/`_V2`, discriminant `version`). Carries a hand-added `model_validator(mode='before')` / `root_validator(pre=True)` that coerces a legacy `DeepgramListenProviderV1`/`V2` (or a dict lacking the `version` discriminant) into the new shape so existing callers keep working. Remove and unfreeze when the old provider payloads are retired in a future major. NOTE: this patch was silently lost once (it was absent from `.fernignore`, so a regen overwrote it) — keep it frozen. - `tests/wire/test_manage_v1_projects_keys.py` — restored wire coverage for the legacy `CreateKeyV1RequestOneParams` request alias so future regens do not silently drop that compatibility check diff --git a/src/deepgram/listen/v2/types/_dict_compat.py b/src/deepgram/listen/v2/types/_dict_compat.py new file mode 100644 index 00000000..1f793d49 --- /dev/null +++ b/src/deepgram/listen/v2/types/_dict_compat.py @@ -0,0 +1,26 @@ +import typing + +from ....core.pydantic_utilities import IS_PYDANTIC_V2 +from ....core.unchecked_base_model import UncheckedBaseModel + + +class ListenV2ResponseDictCompatModel(UncheckedBaseModel): + def __getitem__(self, key: str) -> typing.Any: + model = typing.cast(typing.Any, self) + fields = type(model).model_fields if IS_PYDANTIC_V2 else type(model).__fields__ + fields_set = model.model_fields_set if IS_PYDANTIC_V2 else model.__fields_set__ + + for field_name, field in fields.items(): + if (field.alias or field_name) == key: + if field_name not in fields_set: + raise KeyError(key) + return getattr(model, field_name) + + if IS_PYDANTIC_V2: + extras = model.__pydantic_extra__ or {} + if key in extras: + return extras[key] + elif key in fields_set and key in model.__dict__: + return model.__dict__[key] + + raise KeyError(key) diff --git a/src/deepgram/listen/v2/types/listen_v2configure_failure.py b/src/deepgram/listen/v2/types/listen_v2configure_failure.py index 2d0cb34f..bf30e353 100644 --- a/src/deepgram/listen/v2/types/listen_v2configure_failure.py +++ b/src/deepgram/listen/v2/types/listen_v2configure_failure.py @@ -4,10 +4,10 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 -from ....core.unchecked_base_model import UncheckedBaseModel +from ._dict_compat import ListenV2ResponseDictCompatModel -class ListenV2ConfigureFailure(UncheckedBaseModel): +class ListenV2ConfigureFailure(ListenV2ResponseDictCompatModel): type: typing.Literal["ConfigureFailure"] = pydantic.Field(default="ConfigureFailure") """ Message type identifier diff --git a/src/deepgram/listen/v2/types/listen_v2configure_success.py b/src/deepgram/listen/v2/types/listen_v2configure_success.py index ce20724a..bde84d6f 100644 --- a/src/deepgram/listen/v2/types/listen_v2configure_success.py +++ b/src/deepgram/listen/v2/types/listen_v2configure_success.py @@ -4,12 +4,12 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 -from ....core.unchecked_base_model import UncheckedBaseModel from ....types.listen_v2keyterm import ListenV2Keyterm +from ._dict_compat import ListenV2ResponseDictCompatModel from .listen_v2configure_success_thresholds import ListenV2ConfigureSuccessThresholds -class ListenV2ConfigureSuccess(UncheckedBaseModel): +class ListenV2ConfigureSuccess(ListenV2ResponseDictCompatModel): type: typing.Literal["ConfigureSuccess"] = pydantic.Field(default="ConfigureSuccess") """ Message type identifier diff --git a/src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py b/src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py index 6c719233..e0353ec9 100644 --- a/src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py +++ b/src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py @@ -4,13 +4,13 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 -from ....core.unchecked_base_model import UncheckedBaseModel from ....types.listen_v2eager_eot_threshold import ListenV2EagerEotThreshold from ....types.listen_v2eot_threshold import ListenV2EotThreshold from ....types.listen_v2eot_timeout_ms import ListenV2EotTimeoutMs +from ._dict_compat import ListenV2ResponseDictCompatModel -class ListenV2ConfigureSuccessThresholds(UncheckedBaseModel): +class ListenV2ConfigureSuccessThresholds(ListenV2ResponseDictCompatModel): """ Updates each parameter, if it is supplied. If a particular threshold parameter is not supplied, the configuration continues using the currently configured value. diff --git a/src/deepgram/listen/v2/types/listen_v2connected.py b/src/deepgram/listen/v2/types/listen_v2connected.py index cefc0236..b253d86c 100644 --- a/src/deepgram/listen/v2/types/listen_v2connected.py +++ b/src/deepgram/listen/v2/types/listen_v2connected.py @@ -4,10 +4,10 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 -from ....core.unchecked_base_model import UncheckedBaseModel +from ._dict_compat import ListenV2ResponseDictCompatModel -class ListenV2Connected(UncheckedBaseModel): +class ListenV2Connected(ListenV2ResponseDictCompatModel): type: typing.Literal["Connected"] = pydantic.Field(default="Connected") """ Message type identifier diff --git a/src/deepgram/listen/v2/types/listen_v2fatal_error.py b/src/deepgram/listen/v2/types/listen_v2fatal_error.py index c5106f07..2202c82e 100644 --- a/src/deepgram/listen/v2/types/listen_v2fatal_error.py +++ b/src/deepgram/listen/v2/types/listen_v2fatal_error.py @@ -4,10 +4,10 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 -from ....core.unchecked_base_model import UncheckedBaseModel +from ._dict_compat import ListenV2ResponseDictCompatModel -class ListenV2FatalError(UncheckedBaseModel): +class ListenV2FatalError(ListenV2ResponseDictCompatModel): type: typing.Literal["Error"] = pydantic.Field(default="Error") """ Message type identifier diff --git a/src/deepgram/listen/v2/types/listen_v2turn_info.py b/src/deepgram/listen/v2/types/listen_v2turn_info.py index dd60abf5..d8fd72b9 100644 --- a/src/deepgram/listen/v2/types/listen_v2turn_info.py +++ b/src/deepgram/listen/v2/types/listen_v2turn_info.py @@ -4,12 +4,12 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 -from ....core.unchecked_base_model import UncheckedBaseModel +from ._dict_compat import ListenV2ResponseDictCompatModel from .listen_v2turn_info_event import ListenV2TurnInfoEvent from .listen_v2turn_info_words_item import ListenV2TurnInfoWordsItem -class ListenV2TurnInfo(UncheckedBaseModel): +class ListenV2TurnInfo(ListenV2ResponseDictCompatModel): """ Describes the current turn and latest state of the turn """ diff --git a/src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py b/src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py index 07ada673..88240cd9 100644 --- a/src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py +++ b/src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py @@ -4,10 +4,10 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 -from ....core.unchecked_base_model import UncheckedBaseModel +from ._dict_compat import ListenV2ResponseDictCompatModel -class ListenV2TurnInfoWordsItem(UncheckedBaseModel): +class ListenV2TurnInfoWordsItem(ListenV2ResponseDictCompatModel): word: str = pydantic.Field() """ The individual punctuated, properly-cased word from the transcript diff --git a/tests/custom/test_model_dict_compat.py b/tests/custom/test_model_dict_compat.py new file mode 100644 index 00000000..93b215bf --- /dev/null +++ b/tests/custom/test_model_dict_compat.py @@ -0,0 +1,98 @@ +"""Regression coverage for read-only dictionary access on typed responses. + +Listen V2 responses were raw dictionaries through SDK 7.6 because the response +union contained ``typing.Any``. SDK 7.7 fixed deserialization to return typed +models, which broke callers using the observed dictionary interface. Listen V2 +response models now support both attribute and subscript access during that +transition. +""" + +import json +import typing + +import pytest + +from deepgram.listen.v2.socket_client import AsyncV2SocketClient, V2SocketClient +from deepgram.listen.v2.types.listen_v2configure_failure import ListenV2ConfigureFailure +from deepgram.listen.v2.types.listen_v2configure_success import ListenV2ConfigureSuccess +from deepgram.listen.v2.types.listen_v2configure_success_thresholds import ListenV2ConfigureSuccessThresholds +from deepgram.listen.v2.types.listen_v2connected import ListenV2Connected +from deepgram.listen.v2.types.listen_v2fatal_error import ListenV2FatalError +from deepgram.listen.v2.types.listen_v2turn_info import ListenV2TurnInfo +from deepgram.types.get_model_v1response_metadata import GetModelV1ResponseMetadata + +TURN_INFO = { + "type": "TurnInfo", + "request_id": "request-id", + "sequence_id": 1, + "event": "EndOfTurn", + "turn_index": 0, + "audio_window_start": 0.0, + "audio_window_end": 1.0, + "transcript": "hello", + "words": [{"word": "hello", "confidence": 0.96}], + "end_of_turn_confidence": 0.9, + "future_field": "preserved", +} + + +class _FakeWebSocket: + def recv(self) -> str: + return json.dumps(TURN_INFO) + + +class _FakeAsyncWebSocket: + async def recv(self) -> str: + return json.dumps(TURN_INFO) + + +def _assert_attribute_and_subscript_access(message: object) -> None: + assert isinstance(message, ListenV2TurnInfo) + + assert message.transcript == "hello" + assert message.words[0].confidence == 0.96 + + assert message["transcript"] == "hello" + assert message.words[0]["confidence"] == 0.96 + assert message["words"][0]["confidence"] == 0.96 + assert message["future_field"] == "preserved" + with pytest.raises(KeyError): + message["languages"] + + +def test_sync_listen_v2_response_supports_both_access_styles() -> None: + message = V2SocketClient(websocket=typing.cast(typing.Any, _FakeWebSocket())).recv() + _assert_attribute_and_subscript_access(message) + + +async def test_async_listen_v2_response_supports_both_access_styles() -> None: + message = await AsyncV2SocketClient(websocket=typing.cast(typing.Any, _FakeAsyncWebSocket())).recv() + _assert_attribute_and_subscript_access(message) + + +def test_all_listen_v2_response_models_support_subscript_access() -> None: + configure_success = ListenV2ConfigureSuccess( + type="ConfigureSuccess", + request_id="request-id", + thresholds=ListenV2ConfigureSuccessThresholds(eot_threshold=0.7), + keyterms=[], + sequence_id=2, + ) + responses: typing.List[typing.Any] = [ + ListenV2Connected(type="Connected", request_id="request-id", sequence_id=0), + ListenV2ConfigureFailure(type="ConfigureFailure", request_id="request-id", sequence_id=1), + configure_success, + ListenV2FatalError(type="Error", sequence_id=3, code="ERROR", description="failure"), + ] + + for response in responses: + assert response["type"] == response.type + assert configure_success.thresholds["eot_threshold"] == 0.7 + + +def test_unrelated_models_do_not_gain_subscript_access() -> None: + metadata = GetModelV1ResponseMetadata(uuid="model-id") + + assert metadata.uuid_ == "model-id" + with pytest.raises(TypeError, match="not subscriptable"): + metadata["uuid"] # type: ignore[index]