Skip to content
Open
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
1 change: 1 addition & 0 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ src/deepgram/listen/v2/types/_dict_compat.py
# - _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
# - agent/v1 send_raw: public raw-control sender for protocol-transparent bridges
# - listen/v2 send_configure: runtime tolerance for a raw dict alongside the
# generated ListenV2Configure model
# [temporarily frozen — manual patches listed above]
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Current temporarily frozen files:
- `src/deepgram/types/speak_settings_v1provider.py`, `src/deepgram/types/deepgram.py` — validate Agent TTS `expressivity` as `pydantic.StrictInt` so Pydantic v1 rejects fractional values instead of truncating them before they reach the API. Regression coverage in `tests/custom/test_socket_client_shims.py`. Unfreeze when Fern emits a strict integer.
- `src/deepgram/listen/v1/socket_client.py` — same
- `src/deepgram/listen/v2/socket_client.py` — same (broad except, optional `send_close_stream` default). As of the 2026-08-11 regen the generator properly types `send_configure(ListenV2Configure)` and puts `ListenV2ConfigureSuccess` in the response Union, so those are taken from the generator; the only `send_configure` patch retained is runtime tolerance for a raw dict (sent verbatim) for back-compat with pre-typed-model callers
- `src/deepgram/agent/v1/socket_client.py` — same + `_sanitize_numeric_types`
- `src/deepgram/agent/v1/socket_client.py` — same + `_sanitize_numeric_types` and public `send_raw()` for protocol-transparent control-frame bridges
- `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.
Expand Down
18 changes: 18 additions & 0 deletions src/deepgram/agent/v1/socket_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,15 @@ async def send_force_end_turn(self, message: typing.Optional[AgentV1ForceEndTurn
"""
await self._send_model(message or AgentV1ForceEndTurn(type="ForceEndTurn"))

async def send_raw(self, message: typing.Union[typing.Dict[str, typing.Any], str]) -> None:
"""Send a JSON control frame without model validation.

Dictionaries are serialized to JSON. Serialized JSON strings are sent
unchanged, allowing protocol-transparent bridges to forward unknown
control frames.
"""
await self._send(message)

async def send_media(self, message: bytes) -> None:
"""
Send a message to the websocket connection.
Expand Down Expand Up @@ -352,6 +361,15 @@ def send_force_end_turn(self, message: typing.Optional[AgentV1ForceEndTurn] = No
"""
self._send_model(message or AgentV1ForceEndTurn(type="ForceEndTurn"))

def send_raw(self, message: typing.Union[typing.Dict[str, typing.Any], str]) -> None:
"""Send a JSON control frame without model validation.

Dictionaries are serialized to JSON. Serialized JSON strings are sent
unchanged, allowing protocol-transparent bridges to forward unknown
control frames.
"""
self._send(message)

def send_media(self, message: bytes) -> None:
"""
Send a message to the websocket connection.
Expand Down
24 changes: 24 additions & 0 deletions tests/custom/test_socket_client_shims.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,30 @@ async def test_agent_force_end_turn_async_no_arg(self):
assert _sent_json(ws) == {"type": "ForceEndTurn"}


class TestAgentRawControlSender:
def test_sync_send_raw_serializes_an_unknown_control_frame(self):
ws = _FakeWebSocket()
V1SocketClient(websocket=ws).send_raw({"type": "FutureControl", "sample_rate": 44100.0})
assert ws.sent == ['{"type": "FutureControl", "sample_rate": 44100.0}']

def test_sync_send_raw_preserves_serialized_json(self):
ws = _FakeWebSocket()
message = '{"type":"FutureControl","option":true}'
V1SocketClient(websocket=ws).send_raw(message)
assert ws.sent == [message]

async def test_async_send_raw_serializes_an_unknown_control_frame(self):
ws = _FakeAsyncWebSocket()
await AsyncV1SocketClient(websocket=ws).send_raw({"type": "FutureControl", "sample_rate": 44100.0})
assert ws.sent == ['{"type": "FutureControl", "sample_rate": 44100.0}']

async def test_async_send_raw_preserves_serialized_json(self):
ws = _FakeAsyncWebSocket()
message = '{"type":"FutureControl","option":true}'
await AsyncV1SocketClient(websocket=ws).send_raw(message)
assert ws.sent == [message]


class TestAgentSettingsSerialization:
@pytest.mark.parametrize("expressivity", [1.5, True, "2"])
def test_expressivity_requires_a_plain_integer(self, expressivity):
Expand Down
Loading