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
23 changes: 21 additions & 2 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions src/deepgram/listen/v2/types/_dict_compat.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions src/deepgram/listen/v2/types/listen_v2configure_failure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/deepgram/listen/v2/types/listen_v2configure_success.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/deepgram/listen/v2/types/listen_v2connected.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/deepgram/listen/v2/types/listen_v2fatal_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/deepgram/listen/v2/types/listen_v2turn_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions tests/custom/test_model_dict_compat.py
Original file line number Diff line number Diff line change
@@ -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]
Loading