From af84dcbd4210ee858c3aa7bebefe51a76d8f7239 Mon Sep 17 00:00:00 2001 From: Luke Oliff Date: Thu, 29 Jan 2026 20:13:46 +0000 Subject: [PATCH 1/3] fix(websockets): support array parameters in Listen v1 and v2 clients Update WebSocket clients to properly handle parameters that accept both single strings and arrays of strings. Arrays are now correctly expanded into multiple query parameters (e.g., keyterm=term1&keyterm=term2) instead of being URL-encoded as a single stringified array. Fixed parameters in Listen V2: - keyterm: now accepts string | string[] - tag: now accepts string | string[] Fixed parameters in Listen V1: - keyterm: now accepts string | string[] - keywords: now accepts string | string[] - extra: now accepts string | string[] - redact: now accepts string | string[] - replace: now accepts string | string[] - search: now accepts string | string[] - tag: now accepts string | string[] Changes: - Updated client method signatures to accept Union[str, Sequence[str]] - Added runtime type checking to detect and iterate over arrays - Updated type definitions to reflect proper union types - Added documentation for array parameter support This fix ensures consistency with the AsyncAPI/OpenAPI specs and matches the behavior of the REST API clients. Fixes #648 Fixes #629 Related to #616 --- src/deepgram/listen/v1/client.py | 112 ++++++++++++++++++------ src/deepgram/listen/v2/client.py | 44 +++++++--- src/deepgram/types/listen_v1extra.py | 2 +- src/deepgram/types/listen_v1keyterm.py | 2 +- src/deepgram/types/listen_v1keywords.py | 2 +- src/deepgram/types/listen_v1redact.py | 3 +- src/deepgram/types/listen_v1replace.py | 2 +- src/deepgram/types/listen_v1search.py | 2 +- src/deepgram/types/listen_v1tag.py | 2 +- src/deepgram/types/listen_v2tag.py | 2 +- 10 files changed, 125 insertions(+), 48 deletions(-) diff --git a/src/deepgram/listen/v1/client.py b/src/deepgram/listen/v1/client.py index c6cba2d8..cf0110ef 100644 --- a/src/deepgram/listen/v1/client.py +++ b/src/deepgram/listen/v1/client.py @@ -51,10 +51,10 @@ def connect( dictation: typing.Optional[str] = None, encoding: typing.Optional[str] = None, endpointing: typing.Optional[str] = None, - extra: typing.Optional[str] = None, + extra: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, interim_results: typing.Optional[str] = None, - keyterm: typing.Optional[str] = None, - keywords: typing.Optional[str] = None, + keyterm: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, + keywords: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, language: typing.Optional[str] = None, mip_opt_out: typing.Optional[str] = None, model: str, @@ -62,12 +62,12 @@ def connect( numerals: typing.Optional[str] = None, profanity_filter: typing.Optional[str] = None, punctuate: typing.Optional[str] = None, - redact: typing.Optional[str] = None, - replace: typing.Optional[str] = None, + redact: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, + replace: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, sample_rate: typing.Optional[str] = None, - search: typing.Optional[str] = None, + search: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, smart_format: typing.Optional[str] = None, - tag: typing.Optional[str] = None, + tag: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, utterance_end_ms: typing.Optional[str] = None, vad_events: typing.Optional[str] = None, version: typing.Optional[str] = None, @@ -163,13 +163,25 @@ def connect( if endpointing is not None: query_params = query_params.add("endpointing", endpointing) if extra is not None: - query_params = query_params.add("extra", extra) + if isinstance(extra, (list, tuple)): + for item in extra: + query_params = query_params.add("extra", str(item)) + else: + query_params = query_params.add("extra", extra) if interim_results is not None: query_params = query_params.add("interim_results", interim_results) if keyterm is not None: - query_params = query_params.add("keyterm", keyterm) + if isinstance(keyterm, (list, tuple)): + for term in keyterm: + query_params = query_params.add("keyterm", str(term)) + else: + query_params = query_params.add("keyterm", keyterm) if keywords is not None: - query_params = query_params.add("keywords", keywords) + if isinstance(keywords, (list, tuple)): + for keyword in keywords: + query_params = query_params.add("keywords", str(keyword)) + else: + query_params = query_params.add("keywords", keywords) if language is not None: query_params = query_params.add("language", language) if mip_opt_out is not None: @@ -185,17 +197,33 @@ def connect( if punctuate is not None: query_params = query_params.add("punctuate", punctuate) if redact is not None: - query_params = query_params.add("redact", redact) + if isinstance(redact, (list, tuple)): + for item in redact: + query_params = query_params.add("redact", str(item)) + else: + query_params = query_params.add("redact", redact) if replace is not None: - query_params = query_params.add("replace", replace) + if isinstance(replace, (list, tuple)): + for item in replace: + query_params = query_params.add("replace", str(item)) + else: + query_params = query_params.add("replace", replace) if sample_rate is not None: query_params = query_params.add("sample_rate", sample_rate) if search is not None: - query_params = query_params.add("search", search) + if isinstance(search, (list, tuple)): + for item in search: + query_params = query_params.add("search", str(item)) + else: + query_params = query_params.add("search", search) if smart_format is not None: query_params = query_params.add("smart_format", smart_format) if tag is not None: - query_params = query_params.add("tag", tag) + if isinstance(tag, (list, tuple)): + for item in tag: + query_params = query_params.add("tag", str(item)) + else: + query_params = query_params.add("tag", tag) if utterance_end_ms is not None: query_params = query_params.add("utterance_end_ms", utterance_end_ms) if vad_events is not None: @@ -262,10 +290,10 @@ async def connect( dictation: typing.Optional[str] = None, encoding: typing.Optional[str] = None, endpointing: typing.Optional[str] = None, - extra: typing.Optional[str] = None, + extra: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, interim_results: typing.Optional[str] = None, - keyterm: typing.Optional[str] = None, - keywords: typing.Optional[str] = None, + keyterm: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, + keywords: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, language: typing.Optional[str] = None, mip_opt_out: typing.Optional[str] = None, model: str, @@ -273,12 +301,12 @@ async def connect( numerals: typing.Optional[str] = None, profanity_filter: typing.Optional[str] = None, punctuate: typing.Optional[str] = None, - redact: typing.Optional[str] = None, - replace: typing.Optional[str] = None, + redact: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, + replace: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, sample_rate: typing.Optional[str] = None, - search: typing.Optional[str] = None, + search: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, smart_format: typing.Optional[str] = None, - tag: typing.Optional[str] = None, + tag: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, utterance_end_ms: typing.Optional[str] = None, vad_events: typing.Optional[str] = None, version: typing.Optional[str] = None, @@ -374,13 +402,25 @@ async def connect( if endpointing is not None: query_params = query_params.add("endpointing", endpointing) if extra is not None: - query_params = query_params.add("extra", extra) + if isinstance(extra, (list, tuple)): + for item in extra: + query_params = query_params.add("extra", str(item)) + else: + query_params = query_params.add("extra", extra) if interim_results is not None: query_params = query_params.add("interim_results", interim_results) if keyterm is not None: - query_params = query_params.add("keyterm", keyterm) + if isinstance(keyterm, (list, tuple)): + for term in keyterm: + query_params = query_params.add("keyterm", str(term)) + else: + query_params = query_params.add("keyterm", keyterm) if keywords is not None: - query_params = query_params.add("keywords", keywords) + if isinstance(keywords, (list, tuple)): + for keyword in keywords: + query_params = query_params.add("keywords", str(keyword)) + else: + query_params = query_params.add("keywords", keywords) if language is not None: query_params = query_params.add("language", language) if mip_opt_out is not None: @@ -396,17 +436,33 @@ async def connect( if punctuate is not None: query_params = query_params.add("punctuate", punctuate) if redact is not None: - query_params = query_params.add("redact", redact) + if isinstance(redact, (list, tuple)): + for item in redact: + query_params = query_params.add("redact", str(item)) + else: + query_params = query_params.add("redact", redact) if replace is not None: - query_params = query_params.add("replace", replace) + if isinstance(replace, (list, tuple)): + for item in replace: + query_params = query_params.add("replace", str(item)) + else: + query_params = query_params.add("replace", replace) if sample_rate is not None: query_params = query_params.add("sample_rate", sample_rate) if search is not None: - query_params = query_params.add("search", search) + if isinstance(search, (list, tuple)): + for item in search: + query_params = query_params.add("search", str(item)) + else: + query_params = query_params.add("search", search) if smart_format is not None: query_params = query_params.add("smart_format", smart_format) if tag is not None: - query_params = query_params.add("tag", tag) + if isinstance(tag, (list, tuple)): + for item in tag: + query_params = query_params.add("tag", str(item)) + else: + query_params = query_params.add("tag", tag) if utterance_end_ms is not None: query_params = query_params.add("utterance_end_ms", utterance_end_ms) if vad_events is not None: diff --git a/src/deepgram/listen/v2/client.py b/src/deepgram/listen/v2/client.py index ff78c81d..415bf99e 100644 --- a/src/deepgram/listen/v2/client.py +++ b/src/deepgram/listen/v2/client.py @@ -43,9 +43,9 @@ def connect( eager_eot_threshold: typing.Optional[str] = None, eot_threshold: typing.Optional[str] = None, eot_timeout_ms: typing.Optional[str] = None, - keyterm: typing.Optional[str] = None, + keyterm: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, mip_opt_out: typing.Optional[str] = None, - tag: typing.Optional[str] = None, + tag: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, authorization: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, ) -> typing.Iterator[V2SocketClient]: @@ -67,11 +67,13 @@ def connect( eot_timeout_ms : typing.Optional[str] - keyterm : typing.Optional[str] + keyterm : typing.Optional[typing.Union[str, typing.Sequence[str]]] + Keyterm prompting can improve recognition of specialized terminology. Pass a single string or a list of strings. mip_opt_out : typing.Optional[str] - tag : typing.Optional[str] + tag : typing.Optional[typing.Union[str, typing.Sequence[str]]] + Label your requests for the purpose of identification during usage reporting. Pass a single string or a list of strings. authorization : typing.Optional[str] Use your API key for authentication, or alternatively generate a [temporary token](/guides/fundamentals/token-based-authentication) and pass it via the `token` query parameter. @@ -100,11 +102,19 @@ def connect( if eot_timeout_ms is not None: query_params = query_params.add("eot_timeout_ms", eot_timeout_ms) if keyterm is not None: - query_params = query_params.add("keyterm", keyterm) + if isinstance(keyterm, (list, tuple)): + for term in keyterm: + query_params = query_params.add("keyterm", str(term)) + else: + query_params = query_params.add("keyterm", keyterm) if mip_opt_out is not None: query_params = query_params.add("mip_opt_out", mip_opt_out) if tag is not None: - query_params = query_params.add("tag", tag) + if isinstance(tag, (list, tuple)): + for t in tag: + query_params = query_params.add("tag", str(t)) + else: + query_params = query_params.add("tag", tag) ws_url = ws_url + f"?{query_params}" headers = self._raw_client._client_wrapper.get_headers() if authorization is not None: @@ -154,9 +164,9 @@ async def connect( eager_eot_threshold: typing.Optional[str] = None, eot_threshold: typing.Optional[str] = None, eot_timeout_ms: typing.Optional[str] = None, - keyterm: typing.Optional[str] = None, + keyterm: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, mip_opt_out: typing.Optional[str] = None, - tag: typing.Optional[str] = None, + tag: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, authorization: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, ) -> typing.AsyncIterator[AsyncV2SocketClient]: @@ -178,11 +188,13 @@ async def connect( eot_timeout_ms : typing.Optional[str] - keyterm : typing.Optional[str] + keyterm : typing.Optional[typing.Union[str, typing.Sequence[str]]] + Keyterm prompting can improve recognition of specialized terminology. Pass a single string or a list of strings. mip_opt_out : typing.Optional[str] - tag : typing.Optional[str] + tag : typing.Optional[typing.Union[str, typing.Sequence[str]]] + Label your requests for the purpose of identification during usage reporting. Pass a single string or a list of strings. authorization : typing.Optional[str] Use your API key for authentication, or alternatively generate a [temporary token](/guides/fundamentals/token-based-authentication) and pass it via the `token` query parameter. @@ -211,11 +223,19 @@ async def connect( if eot_timeout_ms is not None: query_params = query_params.add("eot_timeout_ms", eot_timeout_ms) if keyterm is not None: - query_params = query_params.add("keyterm", keyterm) + if isinstance(keyterm, (list, tuple)): + for term in keyterm: + query_params = query_params.add("keyterm", str(term)) + else: + query_params = query_params.add("keyterm", keyterm) if mip_opt_out is not None: query_params = query_params.add("mip_opt_out", mip_opt_out) if tag is not None: - query_params = query_params.add("tag", tag) + if isinstance(tag, (list, tuple)): + for t in tag: + query_params = query_params.add("tag", str(t)) + else: + query_params = query_params.add("tag", tag) ws_url = ws_url + f"?{query_params}" headers = self._raw_client._client_wrapper.get_headers() if authorization is not None: diff --git a/src/deepgram/types/listen_v1extra.py b/src/deepgram/types/listen_v1extra.py index 299c0244..6c4248ce 100644 --- a/src/deepgram/types/listen_v1extra.py +++ b/src/deepgram/types/listen_v1extra.py @@ -2,4 +2,4 @@ import typing -ListenV1Extra = typing.Optional[typing.Any] +ListenV1Extra = typing.Optional[typing.Union[str, typing.Sequence[str]]] diff --git a/src/deepgram/types/listen_v1keyterm.py b/src/deepgram/types/listen_v1keyterm.py index eb3b1b44..5651069a 100644 --- a/src/deepgram/types/listen_v1keyterm.py +++ b/src/deepgram/types/listen_v1keyterm.py @@ -2,4 +2,4 @@ import typing -ListenV1Keyterm = typing.Optional[typing.Any] +ListenV1Keyterm = typing.Optional[typing.Union[str, typing.Sequence[str]]] diff --git a/src/deepgram/types/listen_v1keywords.py b/src/deepgram/types/listen_v1keywords.py index 0be380b6..4c4330db 100644 --- a/src/deepgram/types/listen_v1keywords.py +++ b/src/deepgram/types/listen_v1keywords.py @@ -2,4 +2,4 @@ import typing -ListenV1Keywords = typing.Optional[typing.Any] +ListenV1Keywords = typing.Optional[typing.Union[str, typing.Sequence[str]]] diff --git a/src/deepgram/types/listen_v1redact.py b/src/deepgram/types/listen_v1redact.py index 7bf572f3..770ec368 100644 --- a/src/deepgram/types/listen_v1redact.py +++ b/src/deepgram/types/listen_v1redact.py @@ -3,5 +3,6 @@ import typing ListenV1Redact = typing.Union[ - typing.Literal["true", "false", "pci", "numbers", "aggressive_numbers", "ssn"], typing.Any + typing.Literal["true", "false", "pci", "numbers", "aggressive_numbers", "ssn"], + typing.Sequence[str], ] diff --git a/src/deepgram/types/listen_v1replace.py b/src/deepgram/types/listen_v1replace.py index 5914cbfa..4bd11cc4 100644 --- a/src/deepgram/types/listen_v1replace.py +++ b/src/deepgram/types/listen_v1replace.py @@ -2,4 +2,4 @@ import typing -ListenV1Replace = typing.Optional[typing.Any] +ListenV1Replace = typing.Optional[typing.Union[str, typing.Sequence[str]]] diff --git a/src/deepgram/types/listen_v1search.py b/src/deepgram/types/listen_v1search.py index 6eebf50b..45012296 100644 --- a/src/deepgram/types/listen_v1search.py +++ b/src/deepgram/types/listen_v1search.py @@ -2,4 +2,4 @@ import typing -ListenV1Search = typing.Optional[typing.Any] +ListenV1Search = typing.Optional[typing.Union[str, typing.Sequence[str]]] diff --git a/src/deepgram/types/listen_v1tag.py b/src/deepgram/types/listen_v1tag.py index 75087a7e..5de7abaf 100644 --- a/src/deepgram/types/listen_v1tag.py +++ b/src/deepgram/types/listen_v1tag.py @@ -2,4 +2,4 @@ import typing -ListenV1Tag = typing.Optional[typing.Any] +ListenV1Tag = typing.Optional[typing.Union[str, typing.Sequence[str]]] diff --git a/src/deepgram/types/listen_v2tag.py b/src/deepgram/types/listen_v2tag.py index 7279fae9..956053a0 100644 --- a/src/deepgram/types/listen_v2tag.py +++ b/src/deepgram/types/listen_v2tag.py @@ -2,4 +2,4 @@ import typing -ListenV2Tag = typing.Optional[typing.Any] +ListenV2Tag = typing.Optional[typing.Union[str, typing.Sequence[str]]] From d835960bc9aefdebcb40a5aa5f1bd22b94d2e6d0 Mon Sep 17 00:00:00 2001 From: Luke Oliff Date: Thu, 29 Jan 2026 20:20:08 +0000 Subject: [PATCH 2/3] fix(types): correct ListenV1Redact type definition - Wrap Union in Optional to match function signature - Add str as standalone option in Union to allow any string value - Maintains Literal types for common values (autocomplete support) Addresses Copilot AI review comments --- src/deepgram/types/listen_v1redact.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/deepgram/types/listen_v1redact.py b/src/deepgram/types/listen_v1redact.py index 770ec368..9187906c 100644 --- a/src/deepgram/types/listen_v1redact.py +++ b/src/deepgram/types/listen_v1redact.py @@ -2,7 +2,10 @@ import typing -ListenV1Redact = typing.Union[ - typing.Literal["true", "false", "pci", "numbers", "aggressive_numbers", "ssn"], - typing.Sequence[str], +ListenV1Redact = typing.Optional[ + typing.Union[ + typing.Literal["true", "false", "pci", "numbers", "aggressive_numbers", "ssn"], + str, + typing.Sequence[str], + ] ] From 6d6d6f80311167edb58c7896bb8c6be2982048e5 Mon Sep 17 00:00:00 2001 From: Luke Oliff Date: Thu, 29 Jan 2026 20:30:06 +0000 Subject: [PATCH 3/3] test: update ListenV1Tag type assertion test Update test to reflect new type definition: - Changed from Optional[Any] to Optional[Union[str, Sequence[str]]] - Updated test name and docstring for clarity - Usage tests remain unchanged and already cover the correct behavior --- tests/unit/test_type_definitions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_type_definitions.py b/tests/unit/test_type_definitions.py index cad86e95..6474dcd7 100644 --- a/tests/unit/test_type_definitions.py +++ b/tests/unit/test_type_definitions.py @@ -53,9 +53,9 @@ def test_listen_v1callback_usage(self): assert isinstance(callback2, str) assert isinstance(callback3, dict) - def test_listen_v1tag_is_optional_any(self): - """Test that ListenV1Tag is Optional[Any].""" - assert ListenV1Tag == typing.Optional[typing.Any] + def test_listen_v1tag_is_optional_union(self): + """Test that ListenV1Tag is Optional[Union[str, Sequence[str]]].""" + assert ListenV1Tag == typing.Optional[typing.Union[str, typing.Sequence[str]]] def test_listen_v1tag_usage(self): """Test that ListenV1Tag can accept None or any value."""