diff --git a/.fern/metadata.json b/.fern/metadata.json index 24c3e5cc..7ffa7aeb 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -1,7 +1,7 @@ { "cliVersion": "5.95.1", "generatorName": "fernapi/fern-python-sdk", - "generatorVersion": "5.27.1", + "generatorVersion": "5.39.3", "generatorConfig": { "client": { "class_name": "BaseClient", @@ -21,7 +21,7 @@ "skip_validation": true } }, - "originGitCommit": "596e7bdfa377f40f3301b1d394c9f0215af5f6d4", + "originGitCommit": "330b1dab41a5004329de2c1ea6396dc4b1ea930c", "originGitCommitIsDirty": true, "invokedBy": "manual", "sdkVersion": "7.17.0" diff --git a/.fernignore b/.fernignore index 01aa1d64..4fc5f71f 100644 --- a/.fernignore +++ b/.fernignore @@ -218,9 +218,7 @@ src/deepgram/listen/v2/types/__init__.py src/deepgram/core/api_error.py src/deepgram/core/parse_error.py -# Pydantic 2.14+ requires explicit generic annotations for these adapters or -# mypy rejects the generated module with var-annotated errors. -src/deepgram/core/pydantic_utilities.py +# Pydantic 2.14+ adapter annotations are now generated natively. src/deepgram/core/query_encoder.py diff --git a/AGENTS.md b/AGENTS.md index 35799c3e..98db2acc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,6 @@ 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/core/pydantic_utilities.py` — Pydantic 2.14+ makes mypy reject Fern's unannotated module-level `TypeAdapter` globals. Retain explicit `TypeAdapter[datetime]` / `TypeAdapter[date]` annotations until Fern emits them. - `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`, `src/deepgram/listen/v2/types/listen_v2warning.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 the language alias when it is retired in a future major. - `src/deepgram/types/shared_topics.py`, `src/deepgram/types/shared_intents.py`, `src/deepgram/requests/shared_topics.py`, `src/deepgram/requests/shared_intents.py`, and the generated root/type/request barrels — direct Topics and Intents segments plus their generated exports remain Fern-owned, but carry the v7 deprecated `results` construction facade and Params shape for the hand-written wrappers above. The models normalize legacy `results.{topics,intents}.segments` input to direct `segments` while preserving direct fields and deprecated read facades. Freeze these generated files during the parent reconciliation; retain all direct generated segments and exports alongside the compatibility surface. diff --git a/poetry.lock b/poetry.lock index 7cdb7366..5578b5d7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -230,14 +230,14 @@ files = [ [[package]] name = "anyio" -version = "4.15.1" +version = "4.16.0" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101"}, - {file = "anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94"}, + {file = "anyio-4.16.0-py3-none-any.whl", hash = "sha256:c66ae634e0b8228396b801741beb3294326d4b6102f7bba4041c0dfb58c29026"}, + {file = "anyio-4.16.0.tar.gz", hash = "sha256:59bb03251907983d1603d27c3087ddb1e93299ea2163adcdba37899a4fb12ffb"}, ] [package.dependencies] @@ -1150,14 +1150,14 @@ reports = ["lxml"] [[package]] name = "mypy-extensions" -version = "1.1.0" +version = "1.2.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, - {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, + {file = "mypy_extensions-1.2.0-py3-none-any.whl", hash = "sha256:993e7a20aa5671eb41a7132cf555af30062da99729601601b9a8382b11cae768"}, + {file = "mypy_extensions-1.2.0.tar.gz", hash = "sha256:c6e275a07f1c2fc096b62940452d0eaa3f3af7de45d704787866240a7c6d5a51"}, ] [[package]] @@ -1550,19 +1550,19 @@ dev = ["argcomplete", "asynctest ; python_version < \"3.11\"", "attrs (>=23.1)", [[package]] name = "pytest-asyncio" -version = "1.4.1" +version = "1.4.1.post1" description = "Pytest support for asyncio" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest_asyncio-1.4.1-py3-none-any.whl", hash = "sha256:507afa78ec77c6661164ef1a2368bc7820383fd1a14b2a7f813de8b5a92949cc"}, - {file = "pytest_asyncio-1.4.1.tar.gz", hash = "sha256:e8266eb094e5ee9ffdb2068c9836f33e209ea77a970eb846933d416b5602669b"}, + {file = "pytest_asyncio-1.4.1.post1-py3-none-any.whl", hash = "sha256:0755e488cc0a2b9bd2eab7292cc6390d4cbb8390f1020745647bae5a708e0165"}, + {file = "pytest_asyncio-1.4.1.post1.tar.gz", hash = "sha256:f582d9ea4a1c082195abe8bd29a1fe106aeb1cd435e3dad2d3bc2749935c36a2"}, ] [package.dependencies] backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} -pytest = ">=8.4,<10" +pytest = ">=8.4" typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} [package.extras] @@ -1780,14 +1780,14 @@ files = [ [[package]] name = "types-python-dateutil" -version = "2.9.0.20261029" +version = "2.9.0.20261104" description = "Typing stubs for python-dateutil" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "types_python_dateutil-2.9.0.20261029-py3-none-any.whl", hash = "sha256:8fd04c6e1722d2523a74d6670596fc0007077651465b686f144ae1d200dcacf5"}, - {file = "types_python_dateutil-2.9.0.20261029.tar.gz", hash = "sha256:3258b491686c441d8074cfc8712308741900c9ff128a06421baec3767c503511"}, + {file = "types_python_dateutil-2.9.0.20261104-py3-none-any.whl", hash = "sha256:f6ee76c91c547716b4795373a53d4c07e8e06cf98384ecb89fcf134d212de2ad"}, + {file = "types_python_dateutil-2.9.0.20261104.tar.gz", hash = "sha256:5d0495702a5f7343741f5d842c531bcd1c941da8b4a85307d52ef43ba80252db"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 25a4e87c..3ffec198 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,8 @@ [project] name = "deepgram-sdk" dynamic = ["version"] +license = "MIT" +license-files = ["LICENSE"] [tool.poetry] name = "deepgram-sdk" @@ -26,8 +28,7 @@ classifiers = [ "Operating System :: POSIX :: Linux", "Operating System :: Microsoft :: Windows", "Topic :: Software Development :: Libraries :: Python Modules", - "Typing :: Typed", - "License :: OSI Approved :: MIT License" + "Typing :: Typed" ] packages = [ { include = "deepgram", from = "src"} diff --git a/src/deepgram/__init__.py b/src/deepgram/__init__.py index 6cc8b9f3..86e0445a 100644 --- a/src/deepgram/__init__.py +++ b/src/deepgram/__init__.py @@ -1375,7 +1375,6 @@ def __dir__(): "voice_agent", ] - __all__.extend( [ "SharedIntentsResults", diff --git a/src/deepgram/agent/__init__.py b/src/deepgram/agent/__init__.py index 636d51d9..23f670de 100644 --- a/src/deepgram/agent/__init__.py +++ b/src/deepgram/agent/__init__.py @@ -493,7 +493,6 @@ def __dir__(): "v1", ] - __all__.extend( [ "AgentV1HistoryContent", diff --git a/src/deepgram/agent/v1/__init__.py b/src/deepgram/agent/v1/__init__.py index 4eea21ce..205d398c 100644 --- a/src/deepgram/agent/v1/__init__.py +++ b/src/deepgram/agent/v1/__init__.py @@ -495,7 +495,6 @@ def __dir__(): "settings", ] - __all__.extend( [ "AgentV1HistoryContent", diff --git a/src/deepgram/agent/v1/requests/__init__.py b/src/deepgram/agent/v1/requests/__init__.py index 121e2fa8..766d9807 100644 --- a/src/deepgram/agent/v1/requests/__init__.py +++ b/src/deepgram/agent/v1/requests/__init__.py @@ -260,7 +260,6 @@ def __dir__(): "FunctionCallHistoryMessageParams", ] - __all__.extend( [ "AgentV1HistoryContentParams", diff --git a/src/deepgram/agent/v1/socket_client.py b/src/deepgram/agent/v1/socket_client.py index 06ec76d6..ef56cc79 100644 --- a/src/deepgram/agent/v1/socket_client.py +++ b/src/deepgram/agent/v1/socket_client.py @@ -48,7 +48,6 @@ except ImportError: from websockets import WebSocketClientProtocol # type: ignore - def _sanitize_numeric_types(obj: typing.Any) -> typing.Any: """Convert whole-number floats to integers for wire-compatible JSON.""" if isinstance(obj, dict): @@ -85,6 +84,7 @@ def _sanitize_numeric_types(obj: typing.Any) -> typing.Any: AgentV1Warning, AgentV1History, bytes, + typing.Dict[str, typing.Any], ] @@ -93,10 +93,10 @@ def __init__(self, *, websocket: WebSocketClientProtocol): super().__init__() self._websocket = websocket - async def __aiter__(self): + async def __aiter__(self) -> typing.AsyncIterator[V1SocketClientResponse]: async for message in self._websocket: if isinstance(message, bytes): - yield message + yield message # type: ignore else: try: yield construct_type(type_=V1SocketClientResponse, object_=json.loads(message)) # type: ignore @@ -272,10 +272,10 @@ def __init__(self, *, websocket: websockets_sync_connection.Connection): super().__init__() self._websocket = websocket - def __iter__(self): + def __iter__(self) -> typing.Iterator[V1SocketClientResponse]: for message in self._websocket: if isinstance(message, bytes): - yield message + yield message # type: ignore else: try: yield construct_type(type_=V1SocketClientResponse, object_=json.loads(message)) # type: ignore diff --git a/src/deepgram/agent/v1/types/__init__.py b/src/deepgram/agent/v1/types/__init__.py index 6d7a13df..e6a3fdc7 100644 --- a/src/deepgram/agent/v1/types/__init__.py +++ b/src/deepgram/agent/v1/types/__init__.py @@ -283,7 +283,6 @@ def __dir__(): "FunctionCallHistoryMessageFunctionCallsItem", ] - __all__.extend( [ "AgentV1HistoryContent", diff --git a/src/deepgram/base_client.py b/src/deepgram/base_client.py index ef741870..7f390949 100644 --- a/src/deepgram/base_client.py +++ b/src/deepgram/base_client.py @@ -75,7 +75,7 @@ def __init__( self, *, environment: DeepgramClientEnvironment = DeepgramClientEnvironment.PRODUCTION, - api_key: typing.Optional[str] = os.getenv("DEEPGRAM_API_KEY"), + api_key: typing.Optional[str] = None, headers: typing.Optional[typing.Dict[str, str]] = None, timeout: typing.Optional[float] = None, max_retries: typing.Optional[int] = None, @@ -87,6 +87,7 @@ def __init__( ): _defaulted_timeout = timeout if timeout is not None else 60 if httpx_client is None else None _defaulted_max_retries = max_retries if max_retries is not None else 2 + api_key = api_key if api_key is not None else os.getenv("DEEPGRAM_API_KEY") if api_key is None: raise ApiError( body="The client must be instantiated be either passing in api_key or setting DEEPGRAM_API_KEY" @@ -251,7 +252,7 @@ def __init__( self, *, environment: DeepgramClientEnvironment = DeepgramClientEnvironment.PRODUCTION, - api_key: typing.Optional[str] = os.getenv("DEEPGRAM_API_KEY"), + api_key: typing.Optional[str] = None, headers: typing.Optional[typing.Dict[str, str]] = None, timeout: typing.Optional[float] = None, max_retries: typing.Optional[int] = None, @@ -263,6 +264,7 @@ def __init__( ): _defaulted_timeout = timeout if timeout is not None else 60 if httpx_client is None else None _defaulted_max_retries = max_retries if max_retries is not None else 2 + api_key = api_key if api_key is not None else os.getenv("DEEPGRAM_API_KEY") if api_key is None: raise ApiError( body="The client must be instantiated be either passing in api_key or setting DEEPGRAM_API_KEY" diff --git a/src/deepgram/client.py b/src/deepgram/client.py index 56e98d11..0a603832 100644 --- a/src/deepgram/client.py +++ b/src/deepgram/client.py @@ -29,6 +29,7 @@ from ._secure_logging import install_websocket_log_redaction from .base_client import AsyncBaseClient, BaseClient +from .core.api_error import ApiError from .transport import install_transport from deepgram.core.client_wrapper import BaseClientWrapper @@ -115,6 +116,12 @@ def __init__(self, *args, **kwargs) -> None: # Set a placeholder api_key if none provided (base client requires it) if kwargs.get("api_key") is None: kwargs["api_key"] = "token" + elif "api_key" in kwargs and kwargs["api_key"] is None: + # Fern now resolves None to DEEPGRAM_API_KEY in BaseClient. Preserve + # the SDK contract that an explicit None disables ambient lookup. + raise ApiError( + body="The client must be instantiated by either passing in api_key or setting DEEPGRAM_API_KEY" + ) elif "api_key" not in kwargs: # The generated base client takes os.getenv("DEEPGRAM_API_KEY") as a default # argument, so it is read once at import. Re-read it here so a key set after @@ -199,6 +206,12 @@ def __init__(self, *args, **kwargs) -> None: # Set a placeholder api_key if none provided (base client requires it) if kwargs.get("api_key") is None: kwargs["api_key"] = "token" + elif "api_key" in kwargs and kwargs["api_key"] is None: + # Fern now resolves None to DEEPGRAM_API_KEY in AsyncBaseClient. + # Preserve the SDK contract that explicit None disables ambient lookup. + raise ApiError( + body="The client must be instantiated by either passing in api_key or setting DEEPGRAM_API_KEY" + ) elif "api_key" not in kwargs: # The generated base client takes os.getenv("DEEPGRAM_API_KEY") as a default # argument, so it is read once at import. Re-read it here so a key set after diff --git a/src/deepgram/core/http_client.py b/src/deepgram/core/http_client.py index 409d2670..2cac2aec 100644 --- a/src/deepgram/core/http_client.py +++ b/src/deepgram/core/http_client.py @@ -184,8 +184,33 @@ def _should_retry(response: httpx.Response) -> bool: ) +_AUTH_HEADERS: typing.FrozenSet[str] = frozenset({"authorization"}) + + def _redact_headers(headers: typing.Dict[str, str]) -> typing.Dict[str, str]: - return {k: ("[REDACTED]" if k.lower() in _SENSITIVE_HEADERS else v) for k, v in headers.items()} + return { + k: ("[REDACTED]" if k.lower() in _SENSITIVE_HEADERS or k.lower() in _AUTH_HEADERS else v) + for k, v in headers.items() + } + + +def _merge_headers(*header_dicts: typing.Optional[typing.Dict[str, typing.Any]]) -> typing.Dict[str, typing.Any]: + """ + Merge header dicts left to right. Header names are case-insensitive, so a later + `x-api-key` replaces an earlier `X-API-Key` instead of producing two headers. + """ + merged: typing.Dict[str, typing.Any] = {} + keys_by_lower: typing.Dict[str, str] = {} + for header_dict in header_dicts: + if not header_dict: + continue + for key, value in header_dict.items(): + existing_key = keys_by_lower.get(key.lower()) + if existing_key is not None: + del merged[existing_key] + keys_by_lower[key.lower()] = key + merged[key] = value + return merged def _build_url(base_url: str, path: typing.Optional[str]) -> str: @@ -272,7 +297,16 @@ def get_request_body( data: typing.Optional[typing.Any], request_options: typing.Optional[RequestOptions], omit: typing.Optional[typing.Any], + optional_body: bool = False, ) -> typing.Tuple[typing.Optional[typing.Any], typing.Optional[typing.Any]]: + # A whole body left at the sentinel was never passed by the caller, so it is absent + # rather than empty: the request carries no content and no `Content-Type`. + if omit is not None: + if json is omit: + json = None + if data is omit: + data = None + json_body = None data_body = None if data is not None: @@ -288,14 +322,36 @@ def get_request_body( # Only collapse empty dict to None when the body was not explicitly provided # and there are no additional body parameters. This preserves explicit empty # bodies (e.g., when an endpoint has a request body type but all fields are optional). - if json_body == {} and json is None and not has_additional_body_parameters: + # `optional_body` marks an endpoint whose body the API does not require, where a body + # that ends up empty means the caller passed none of its properties, so the request is + # sent with no content and no `Content-Type`. + if json_body == {} and (json is None or optional_body) and not has_additional_body_parameters: json_body = None - if data_body == {} and data is None and not has_additional_body_parameters: + if data_body == {} and (data is None or optional_body) and not has_additional_body_parameters: data_body = None return json_body, data_body +def drop_content_type_without_body( + headers: typing.Dict[str, typing.Any], + *, + json_body: typing.Optional[typing.Any], + data_body: typing.Optional[typing.Any], + optional_body: bool, +) -> typing.Dict[str, typing.Any]: + """Strip ``Content-Type`` from a request that carries no body. + + ``get_request_body`` drops the body of an ``optional_body`` endpoint when the caller + supplied none of it, but the endpoint still passes the content type it would have used. + A request that sends nothing must not advertise a media type, so a server that branches + on the header sees a bodyless call for what it is. + """ + if not optional_body or json_body is not None or data_body is not None: + return headers + return {key: value for key, value in headers.items() if key.lower() != "content-type"} + + class HttpClient: def __init__( self, @@ -343,6 +399,7 @@ def request( request_options: typing.Optional[RequestOptions] = None, retries: int = 0, omit: typing.Optional[typing.Any] = None, + optional_body: bool = False, force_multipart: typing.Optional[bool] = None, ) -> httpx.Response: base_url = self.get_base_url(base_url) @@ -355,7 +412,9 @@ def request( ) timeout = _timeout if _timeout is not None else httpx.USE_CLIENT_DEFAULT - json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit) + json_body, data_body = get_request_body( + json=json, data=data, request_options=request_options, omit=omit, optional_body=optional_body + ) request_files: typing.Optional[RequestFiles] = ( convert_file_dict_to_httpx_tuples(remove_omit_from_dict(remove_none_from_dict(files), omit)) @@ -391,13 +450,16 @@ def request( _request_url = _build_url(base_url, path) _request_headers = jsonable_encoder( remove_none_from_dict( - { - **self.base_headers(), - **(headers if headers is not None else {}), - **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}), - } + _merge_headers( + self.base_headers(), + headers, + request_options.get("additional_headers") if request_options is not None else None, + ) ) ) + _request_headers = drop_content_type_without_body( + _request_headers, json_body=json_body, data_body=data_body, optional_body=optional_body + ) if self.logger.is_debug(): self.logger.debug( @@ -426,7 +488,12 @@ def request( files=request_files, timeout=timeout, ) - except (httpx.ConnectError, httpx.RemoteProtocolError): + except (httpx.ConnectError, httpx.RemoteProtocolError) as error: + # A protocol error may occur after the server applied the request, even while reading a 200 body. + if isinstance(error, httpx.RemoteProtocolError) and not ( + request_options is not None and request_options.get("retry_remote_protocol_errors", False) + ): + raise if retries < max_retries: time.sleep(_retry_timeout_from_retries(retries=retries)) return self.request( @@ -506,6 +573,7 @@ def stream( request_options: typing.Optional[RequestOptions] = None, retries: int = 0, omit: typing.Optional[typing.Any] = None, + optional_body: bool = False, force_multipart: typing.Optional[bool] = None, ) -> typing.Iterator[httpx.Response]: base_url = self.get_base_url(base_url) @@ -527,7 +595,9 @@ def stream( if (request_files is None or len(request_files) == 0) and force_multipart: request_files = FORCE_MULTIPART - json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit) + json_body, data_body = get_request_body( + json=json, data=data, request_options=request_options, omit=omit, optional_body=optional_body + ) data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) @@ -554,13 +624,16 @@ def stream( _request_url = _build_url(base_url, path) _request_headers = jsonable_encoder( remove_none_from_dict( - { - **self.base_headers(), - **(headers if headers is not None else {}), - **(request_options.get("additional_headers", {}) if request_options is not None else {}), - } + _merge_headers( + self.base_headers(), + headers, + request_options.get("additional_headers") if request_options is not None else None, + ) ) ) + _request_headers = drop_content_type_without_body( + _request_headers, json_body=json_body, data_body=data_body, optional_body=optional_body + ) if self.logger.is_debug(): self.logger.debug( @@ -638,6 +711,7 @@ async def request( request_options: typing.Optional[RequestOptions] = None, retries: int = 0, omit: typing.Optional[typing.Any] = None, + optional_body: bool = False, force_multipart: typing.Optional[bool] = None, ) -> httpx.Response: base_url = self.get_base_url(base_url) @@ -659,7 +733,9 @@ async def request( if (request_files is None or len(request_files) == 0) and force_multipart: request_files = FORCE_MULTIPART - json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit) + json_body, data_body = get_request_body( + json=json, data=data, request_options=request_options, omit=omit, optional_body=optional_body + ) data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) @@ -689,13 +765,16 @@ async def request( _request_url = _build_url(base_url, path) _request_headers = jsonable_encoder( remove_none_from_dict( - { - **_headers, - **(headers if headers is not None else {}), - **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}), - } + _merge_headers( + _headers, + headers, + request_options.get("additional_headers") if request_options is not None else None, + ) ) ) + _request_headers = drop_content_type_without_body( + _request_headers, json_body=json_body, data_body=data_body, optional_body=optional_body + ) if self.logger.is_debug(): self.logger.debug( @@ -724,7 +803,12 @@ async def request( files=request_files, timeout=timeout, ) - except (httpx.ConnectError, httpx.RemoteProtocolError): + except (httpx.ConnectError, httpx.RemoteProtocolError) as error: + # A protocol error may occur after the server applied the request, even while reading a 200 body. + if isinstance(error, httpx.RemoteProtocolError) and not ( + request_options is not None and request_options.get("retry_remote_protocol_errors", False) + ): + raise if retries < max_retries: await asyncio.sleep(_retry_timeout_from_retries(retries=retries)) return await self.request( @@ -804,6 +888,7 @@ async def stream( request_options: typing.Optional[RequestOptions] = None, retries: int = 0, omit: typing.Optional[typing.Any] = None, + optional_body: bool = False, force_multipart: typing.Optional[bool] = None, ) -> typing.AsyncIterator[httpx.Response]: base_url = self.get_base_url(base_url) @@ -825,7 +910,9 @@ async def stream( if (request_files is None or len(request_files) == 0) and force_multipart: request_files = FORCE_MULTIPART - json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit) + json_body, data_body = get_request_body( + json=json, data=data, request_options=request_options, omit=omit, optional_body=optional_body + ) data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) @@ -855,13 +942,16 @@ async def stream( _request_url = _build_url(base_url, path) _request_headers = jsonable_encoder( remove_none_from_dict( - { - **_headers, - **(headers if headers is not None else {}), - **(request_options.get("additional_headers", {}) if request_options is not None else {}), - } + _merge_headers( + _headers, + headers, + request_options.get("additional_headers") if request_options is not None else None, + ) ) ) + _request_headers = drop_content_type_without_body( + _request_headers, json_body=json_body, data_body=data_body, optional_body=optional_body + ) if self.logger.is_debug(): self.logger.debug( diff --git a/src/deepgram/core/http_response.py b/src/deepgram/core/http_response.py index 00bb1096..9aa1e188 100644 --- a/src/deepgram/core/http_response.py +++ b/src/deepgram/core/http_response.py @@ -24,6 +24,10 @@ def headers(self) -> Dict[str, str]: def status_code(self) -> int: return self._response.status_code + @property + def response(self) -> httpx.Response: + return self._response + class HttpResponse(Generic[T], BaseHttpResponse): """HTTP response wrapper that exposes response headers and data.""" diff --git a/src/deepgram/core/pydantic_utilities.py b/src/deepgram/core/pydantic_utilities.py index a8973e6d..7c2c2372 100644 --- a/src/deepgram/core/pydantic_utilities.py +++ b/src/deepgram/core/pydantic_utilities.py @@ -5,6 +5,7 @@ import inspect import json import logging +import weakref from collections import defaultdict from dataclasses import asdict from typing import ( @@ -36,8 +37,8 @@ IS_PYDANTIC_V2 = pydantic.VERSION.startswith("2.") if IS_PYDANTIC_V2: - _datetime_adapter: pydantic.TypeAdapter[dt.datetime] = pydantic.TypeAdapter(dt.datetime) # type: ignore[attr-defined] - _date_adapter: pydantic.TypeAdapter[dt.date] = pydantic.TypeAdapter(dt.date) # type: ignore[attr-defined] + _datetime_adapter = pydantic.TypeAdapter(dt.datetime) # type: ignore[attr-defined,var-annotated] + _date_adapter = pydantic.TypeAdapter(dt.date) # type: ignore[attr-defined,var-annotated] def parse_datetime(value: Any) -> dt.datetime: # type: ignore[misc] if isinstance(value, dt.datetime): @@ -184,6 +185,58 @@ def _get_type_adapter(type_: Type[Any]) -> Any: return adapter +_field_alias_cache: "weakref.WeakKeyDictionary[type, Tuple[Dict[str, str], Tuple[str, ...]]]" = ( + weakref.WeakKeyDictionary() +) + + +def _get_field_aliases(model: type) -> Tuple[Dict[str, str], Tuple[str, ...]]: + """ + Map of field name to Pydantic alias for the fields whose alias differs from their name, together with the + keys that are ambiguous (an alias of one field and the name of another). Computed once per model class. + """ + cached = _field_alias_cache.get(model) + if cached is None: + fields: Mapping[str, Any] = ( + getattr(model, "model_fields", {}) if IS_PYDANTIC_V2 else getattr(model, "__fields__", {}) + ) + name_to_alias: Dict[str, str] = {} + for name, field in fields.items(): + alias = getattr(field, "alias", None) + if alias is not None and alias != name: + name_to_alias[name] = alias + cached = (name_to_alias, tuple(alias for alias in name_to_alias.values() if alias in fields)) + _field_alias_cache[model] = cached + return cached + + +def _coerce_keys_to_aliases(model: type, data: Any) -> Any: + """ + Accept Python field names in input by rewriting them to their Pydantic aliases, + while avoiding silent collisions when a key could refer to multiple fields. + """ + if not isinstance(data, Mapping): + return data + + name_to_alias, ambiguous_keys = _get_field_aliases(model) + for key in ambiguous_keys: + if key in data and name_to_alias.get(key, key) not in data: + raise ValueError( + f"Ambiguous input key '{key}': it is both a field name and an alias. " + "Provide the explicit alias key to disambiguate." + ) + + if not name_to_alias or not any(name in data for name in name_to_alias): + return data if isinstance(data, dict) else dict(data) + + rewritten: Dict[str, Any] = dict(data) + for name, alias in name_to_alias.items(): + if name in data and alias not in rewritten: + rewritten[alias] = rewritten.pop(name) + + return rewritten + + def parse_obj_as(type_: Type[T], object_: Any) -> T: # convert_and_respect_annotation_metadata is required for TypedDict aliasing. # @@ -193,20 +246,7 @@ def parse_obj_as(type_: Type[T], object_: Any) -> T: # - If the model encodes aliasing only via FieldMetadata annotations, then we MUST pre-dealias because Pydantic # will not recognize those aliases during validation. if inspect.isclass(type_) and issubclass(type_, pydantic.BaseModel): - has_pydantic_aliases = False - if IS_PYDANTIC_V2: - for field_name, field_info in getattr(type_, "model_fields", {}).items(): # type: ignore[attr-defined] - alias = getattr(field_info, "alias", None) - if alias is not None and alias != field_name: - has_pydantic_aliases = True - break - else: - for field in getattr(type_, "__fields__", {}).values(): - alias = getattr(field, "alias", None) - name = getattr(field, "name", None) - if alias is not None and name is not None and alias != name: - has_pydantic_aliases = True - break + has_pydantic_aliases = bool(_get_field_aliases(type_)[0]) dealiased_object = ( object_ @@ -239,39 +279,7 @@ class UniversalBaseModel(pydantic.BaseModel): @pydantic.model_validator(mode="before") # type: ignore[attr-defined] @classmethod def _coerce_field_names_to_aliases(cls, data: Any) -> Any: - """ - Accept Python field names in input by rewriting them to their Pydantic aliases, - while avoiding silent collisions when a key could refer to multiple fields. - """ - if not isinstance(data, Mapping): - return data - - fields = getattr(cls, "model_fields", {}) # type: ignore[attr-defined] - name_to_alias: Dict[str, str] = {} - alias_to_name: Dict[str, str] = {} - - for name, field_info in fields.items(): - alias = getattr(field_info, "alias", None) or name - name_to_alias[name] = alias - if alias != name: - alias_to_name[alias] = name - - # Detect ambiguous keys: a key that is an alias for one field and a name for another. - ambiguous_keys = set(alias_to_name.keys()).intersection(set(name_to_alias.keys())) - for key in ambiguous_keys: - if key in data and name_to_alias[key] not in data: - raise ValueError( - f"Ambiguous input key '{key}': it is both a field name and an alias. " - "Provide the explicit alias key to disambiguate." - ) - - original_keys = set(data.keys()) - rewritten: Dict[str, Any] = dict(data) - for name, alias in name_to_alias.items(): - if alias != name and name in original_keys and alias not in rewritten: - rewritten[alias] = rewritten.pop(name) - - return rewritten + return _coerce_keys_to_aliases(cls, data) @pydantic.model_serializer(mode="plain", when_used="json") # type: ignore[attr-defined] def serialize_model(self) -> Any: # type: ignore[name-defined] @@ -287,37 +295,7 @@ class Config: @pydantic.root_validator(pre=True) def _coerce_field_names_to_aliases(cls, values: Any) -> Any: - """ - Pydantic v1 equivalent of _coerce_field_names_to_aliases. - """ - if not isinstance(values, Mapping): - return values - - fields = getattr(cls, "__fields__", {}) - name_to_alias: Dict[str, str] = {} - alias_to_name: Dict[str, str] = {} - - for name, field in fields.items(): - alias = getattr(field, "alias", None) or name - name_to_alias[name] = alias - if alias != name: - alias_to_name[alias] = name - - ambiguous_keys = set(alias_to_name.keys()).intersection(set(name_to_alias.keys())) - for key in ambiguous_keys: - if key in values and name_to_alias[key] not in values: - raise ValueError( - f"Ambiguous input key '{key}': it is both a field name and an alias. " - "Provide the explicit alias key to disambiguate." - ) - - original_keys = set(values.keys()) - rewritten: Dict[str, Any] = dict(values) - for name, alias in name_to_alias.items(): - if alias != name and name in original_keys and alias not in rewritten: - rewritten[alias] = rewritten.pop(name) - - return rewritten + return _coerce_keys_to_aliases(cls, values) # type: ignore[arg-type] @classmethod def model_construct(cls: Type["Model"], _fields_set: Optional[Set[str]] = None, **values: Any) -> "Model": diff --git a/src/deepgram/core/request_options.py b/src/deepgram/core/request_options.py index caa6f669..bd6bc01d 100644 --- a/src/deepgram/core/request_options.py +++ b/src/deepgram/core/request_options.py @@ -20,6 +20,8 @@ class RequestOptions(typing.TypedDict, total=False): - max_retries: int. The max number of retries to attempt if the API call fails. + - retry_remote_protocol_errors: bool. Retry server disconnects and incomplete responses. Defaults to False, since the server may already have applied the request. Enable only when replaying the request is safe; bounded by max_retries. + - additional_headers: typing.Dict[str, typing.Any]. A dictionary containing additional parameters to spread into the request's header dict - additional_query_parameters: typing.Dict[str, typing.Any]. A dictionary containing additional parameters to spread into the request's query parameters dict @@ -32,6 +34,7 @@ class RequestOptions(typing.TypedDict, total=False): timeout: NotRequired[float] timeout_in_seconds: NotRequired[int] max_retries: NotRequired[int] + retry_remote_protocol_errors: NotRequired[bool] additional_headers: NotRequired[typing.Dict[str, typing.Any]] additional_query_parameters: NotRequired[typing.Dict[str, typing.Any]] additional_body_parameters: NotRequired[typing.Dict[str, typing.Any]] diff --git a/src/deepgram/core/unchecked_base_model.py b/src/deepgram/core/unchecked_base_model.py index c5deec1f..e979368a 100644 --- a/src/deepgram/core/unchecked_base_model.py +++ b/src/deepgram/core/unchecked_base_model.py @@ -305,6 +305,10 @@ def _convert_undiscriminated_union_type( except Exception: continue + # No member matched (e.g. a variant added after this SDK was generated, or a missing + # discriminant): keep the original data rather than dropping it. + return object_ + def _convert_union_type( type_: typing.Type[typing.Any], diff --git a/src/deepgram/listen/v1/socket_client.py b/src/deepgram/listen/v1/socket_client.py index b6fa3386..5640c067 100644 --- a/src/deepgram/listen/v1/socket_client.py +++ b/src/deepgram/listen/v1/socket_client.py @@ -33,6 +33,7 @@ ListenV1UtteranceEnd, ListenV1SpeechStarted, ListenV1Error, + typing.Dict[str, typing.Any], ] @@ -41,10 +42,10 @@ def __init__(self, *, websocket: WebSocketClientProtocol): super().__init__() self._websocket = websocket - async def __aiter__(self): + async def __aiter__(self) -> typing.AsyncIterator[V1SocketClientResponse]: async for message in self._websocket: if isinstance(message, bytes): - yield message + yield message # type: ignore else: try: yield construct_type(type_=V1SocketClientResponse, object_=json.loads(message)) # type: ignore @@ -153,10 +154,10 @@ def __init__(self, *, websocket: websockets_sync_connection.Connection): super().__init__() self._websocket = websocket - def __iter__(self): + def __iter__(self) -> typing.Iterator[V1SocketClientResponse]: for message in self._websocket: if isinstance(message, bytes): - yield message + yield message # type: ignore else: try: yield construct_type(type_=V1SocketClientResponse, object_=json.loads(message)) # type: ignore diff --git a/src/deepgram/listen/v2/socket_client.py b/src/deepgram/listen/v2/socket_client.py index 9e3ed469..a91728e8 100644 --- a/src/deepgram/listen/v2/socket_client.py +++ b/src/deepgram/listen/v2/socket_client.py @@ -30,6 +30,7 @@ ListenV2ConfigureFailure, ListenV2Warning, ListenV2FatalError, + typing.Dict[str, typing.Any], ] @@ -38,10 +39,10 @@ def __init__(self, *, websocket: WebSocketClientProtocol): super().__init__() self._websocket = websocket - async def __aiter__(self): + async def __aiter__(self) -> typing.AsyncIterator[V2SocketClientResponse]: async for message in self._websocket: if isinstance(message, bytes): - yield message + yield message # type: ignore else: try: yield construct_type(type_=V2SocketClientResponse, object_=json.loads(message)) # type: ignore @@ -146,10 +147,10 @@ def __init__(self, *, websocket: websockets_sync_connection.Connection): super().__init__() self._websocket = websocket - def __iter__(self): + def __iter__(self) -> typing.Iterator[V2SocketClientResponse]: for message in self._websocket: if isinstance(message, bytes): - yield message + yield message # type: ignore else: try: yield construct_type(type_=V2SocketClientResponse, object_=json.loads(message)) # type: ignore diff --git a/src/deepgram/requests/__init__.py b/src/deepgram/requests/__init__.py index f004ddde..beb2e2db 100644 --- a/src/deepgram/requests/__init__.py +++ b/src/deepgram/requests/__init__.py @@ -601,7 +601,6 @@ def __dir__(): "UsageV1ResponseResolutionParams", ] - __all__.extend( [ "SharedIntentsResultsParams", diff --git a/src/deepgram/speak/v1/socket_client.py b/src/deepgram/speak/v1/socket_client.py index e3c28bb5..f268acb4 100644 --- a/src/deepgram/speak/v1/socket_client.py +++ b/src/deepgram/speak/v1/socket_client.py @@ -22,7 +22,9 @@ from websockets import WebSocketClientProtocol # type: ignore _logger = logging.getLogger(__name__) -V1SocketClientResponse = typing.Union[bytes, SpeakV1Metadata, SpeakV1Flushed, SpeakV1Cleared, SpeakV1Warning] +V1SocketClientResponse = typing.Union[ + bytes, SpeakV1Metadata, SpeakV1Flushed, SpeakV1Cleared, SpeakV1Warning, typing.Dict[str, typing.Any] +] class AsyncV1SocketClient(EventEmitterMixin): @@ -30,10 +32,10 @@ def __init__(self, *, websocket: WebSocketClientProtocol): super().__init__() self._websocket = websocket - async def __aiter__(self): + async def __aiter__(self) -> typing.AsyncIterator[V1SocketClientResponse]: async for message in self._websocket: if isinstance(message, bytes): - yield message + yield message # type: ignore else: try: yield construct_type(type_=V1SocketClientResponse, object_=json.loads(message)) # type: ignore @@ -135,10 +137,10 @@ def __init__(self, *, websocket: websockets_sync_connection.Connection): super().__init__() self._websocket = websocket - def __iter__(self): + def __iter__(self) -> typing.Iterator[V1SocketClientResponse]: for message in self._websocket: if isinstance(message, bytes): - yield message + yield message # type: ignore else: try: yield construct_type(type_=V1SocketClientResponse, object_=json.loads(message)) # type: ignore diff --git a/src/deepgram/speak/v2/socket_client.py b/src/deepgram/speak/v2/socket_client.py index c6729c32..57c5f8ab 100644 --- a/src/deepgram/speak/v2/socket_client.py +++ b/src/deepgram/speak/v2/socket_client.py @@ -41,6 +41,7 @@ SpeakV2ConfigureFailure, SpeakV2Warning, SpeakV2Error, + typing.Dict[str, typing.Any], ] @@ -49,10 +50,10 @@ def __init__(self, *, websocket: WebSocketClientProtocol): super().__init__() self._websocket = websocket - async def __aiter__(self): + async def __aiter__(self) -> typing.AsyncIterator[V2SocketClientResponse]: async for message in self._websocket: if isinstance(message, bytes): - yield message + yield message # type: ignore else: try: yield construct_type(type_=V2SocketClientResponse, object_=json.loads(message)) # type: ignore @@ -161,10 +162,10 @@ def __init__(self, *, websocket: websockets_sync_connection.Connection): super().__init__() self._websocket = websocket - def __iter__(self): + def __iter__(self) -> typing.Iterator[V2SocketClientResponse]: for message in self._websocket: if isinstance(message, bytes): - yield message + yield message # type: ignore else: try: yield construct_type(type_=V2SocketClientResponse, object_=json.loads(message)) # type: ignore diff --git a/src/deepgram/types/__init__.py b/src/deepgram/types/__init__.py index d9b60edd..d3c52c67 100644 --- a/src/deepgram/types/__init__.py +++ b/src/deepgram/types/__init__.py @@ -848,7 +848,6 @@ def __dir__(): "UsageV1ResponseResolution", ] - __all__.extend( [ "SharedIntentsResults", diff --git a/tests/custom/test_core_utilities_coverage.py b/tests/custom/test_core_utilities_coverage.py index 80052c2b..4fd72b9f 100644 --- a/tests/custom/test_core_utilities_coverage.py +++ b/tests/custom/test_core_utilities_coverage.py @@ -339,6 +339,10 @@ def test_construct_type_model_and_unions() -> None: again = construct_type(type_=undiscriminated, object_={"type": "cat", "meow": "m"}) assert isinstance(again, Cat) + # Future server variants must remain observable rather than decoding to None. + unknown = {"type": "future-animal", "details": {"source": "server"}} + assert construct_type(type_=undiscriminated, object_=unknown) == unknown + # plain scalar union assert construct_type(type_=typing.Union[int, str], object_="hello") == "hello" diff --git a/tests/custom/test_http_retry_coverage.py b/tests/custom/test_http_retry_coverage.py index 63030bde..b882b2f9 100644 --- a/tests/custom/test_http_retry_coverage.py +++ b/tests/custom/test_http_retry_coverage.py @@ -8,6 +8,7 @@ """ import typing +from unittest.mock import AsyncMock, MagicMock import httpx import pytest @@ -89,6 +90,21 @@ def test_no_retries_when_max_retries_zero_sync() -> None: _sync_client().manage.v1.projects.list(request_options={"max_retries": 0}) +def test_remote_protocol_error_does_not_retry_by_default_sync(monkeypatch: pytest.MonkeyPatch) -> None: + sleep = MagicMock() + monkeypatch.setattr(http_client_module.time, "sleep", sleep) + + with respx.mock: + route = respx.route(host=HOST) + route.side_effect = httpx.RemoteProtocolError("incomplete response") + + with pytest.raises(httpx.RemoteProtocolError): + _sync_client().manage.v1.projects.list(request_options={"max_retries": 2}) + + assert route.call_count == 1 + sleep.assert_not_called() + + async def test_retry_then_exhaust_async() -> None: with respx.mock: respx.route(host=HOST).mock(return_value=httpx.Response(500, headers={"retry-after": "1"}, json={})) @@ -108,3 +124,18 @@ async def test_retry_on_connect_error_async() -> None: route = respx.route(host=HOST) route.side_effect = [httpx.ConnectError("boom"), httpx.Response(200, json={})] assert await _async_client().manage.v1.projects.list(request_options={"max_retries": 2}) is not None + + +async def test_remote_protocol_error_does_not_retry_by_default_async(monkeypatch: pytest.MonkeyPatch) -> None: + sleep = AsyncMock() + monkeypatch.setattr(http_client_module.asyncio, "sleep", sleep) + + with respx.mock: + route = respx.route(host=HOST) + route.side_effect = httpx.RemoteProtocolError("incomplete response") + + with pytest.raises(httpx.RemoteProtocolError): + await _async_client().manage.v1.projects.list(request_options={"max_retries": 2}) + + assert route.call_count == 1 + sleep.assert_not_awaited() diff --git a/tests/typecheck/compat_aliases.py b/tests/typecheck/compat_aliases.py index 99146d56..85293f95 100644 --- a/tests/typecheck/compat_aliases.py +++ b/tests/typecheck/compat_aliases.py @@ -1,4 +1,4 @@ -from typing import Literal +from typing import Any, Dict, Literal from typing_extensions import assert_type # 3.10-compatible backport (typing.assert_type is 3.11+) @@ -31,6 +31,7 @@ ConversationHistoryMessageParams, FunctionCallHistoryMessageParams, ) +from deepgram.agent.v1.socket_client import V1SocketClientResponse as AgentV1SocketClientResponse from deepgram.agent.v1.types import ( AgentV1ConversationTextConversationalBehavior, AgentV1InjectDynamicAgentMessage, @@ -41,7 +42,9 @@ from deepgram.listen import ListenV1ConfigureFailureCode as RootListenV1ConfigureFailureCode from deepgram.listen.v1 import ListenV1ConfigureFailureCode as V1ListenV1ConfigureFailureCode from deepgram.listen.v1.requests import ListenV1ConfigureFailureParams +from deepgram.listen.v1.socket_client import V1SocketClientResponse as ListenV1SocketClientResponse from deepgram.listen.v1.types import ListenV1ConfigureFailure, ListenV1ConfigureFailureCode +from deepgram.listen.v2.socket_client import V2SocketClientResponse from deepgram.requests import ( CreateKeyV1RequestOneParams, CreateKeyV1RequestParams, @@ -64,6 +67,8 @@ from deepgram.requests import ( ThinkSettingsV1Provider_DeepgramParams as RequestsThinkSettingsV1Provider_DeepgramParams, ) +from deepgram.speak.v1.socket_client import V1SocketClientResponse as SpeakV1SocketClientResponse +from deepgram.speak.v2.socket_client import V2SocketClientResponse as SpeakV2SocketClientResponse from deepgram.types import ( DeepgramThinkLiveProviderV1 as TypesDeepgramThinkLiveProviderV1, ) @@ -233,3 +238,17 @@ legacy_intents: SharedIntentsParams = {"results": {"intents": {"segments": [{"text": "hello"}]}}} assert_type(SharedTopics(results=legacy_topics["results"]), SharedTopics) assert_type(SharedIntents(results=legacy_intents["results"]), SharedIntents) + +# Forward-compatible websocket payloads are raw dictionaries when the server +# sends a message type newer than this SDK. +unknown_socket_payload: Dict[str, Any] = {"type": "FutureMessage"} +agent_socket_response: AgentV1SocketClientResponse = unknown_socket_payload +listen_v1_socket_response: ListenV1SocketClientResponse = unknown_socket_payload +listen_v2_socket_response: V2SocketClientResponse = unknown_socket_payload +speak_v1_socket_response: SpeakV1SocketClientResponse = unknown_socket_payload +speak_v2_socket_response: SpeakV2SocketClientResponse = unknown_socket_payload +assert_type(agent_socket_response, AgentV1SocketClientResponse) +assert_type(listen_v1_socket_response, ListenV1SocketClientResponse) +assert_type(listen_v2_socket_response, V2SocketClientResponse) +assert_type(speak_v1_socket_response, SpeakV1SocketClientResponse) +assert_type(speak_v2_socket_response, SpeakV2SocketClientResponse) diff --git a/tests/utils/test_http_client.py b/tests/utils/test_http_client.py index 8a8f2387..22841a58 100644 --- a/tests/utils/test_http_client.py +++ b/tests/utils/test_http_client.py @@ -10,7 +10,10 @@ AsyncHttpClient, HttpClient, _build_url, + _merge_headers, + _redact_headers, _should_retry, + drop_content_type_without_body, get_request_body, remove_none_from_dict, ) @@ -121,6 +124,67 @@ def test_explicit_empty_json_body_is_preserved() -> None: assert data_body2 == {} +def test_omitted_body_sends_no_content() -> None: + """A body left at the sentinel was never passed, so nothing is sent. + + This is how an endpoint whose body may be omitted stays bodyless, as opposed to + sending an empty ``{}``. An explicit ``None`` still reaches the wire as ``null``. + """ + omit = cast(Any, ...) + unrelated_request_options: RequestOptions = {"max_retries": 3} + + json_body, data_body = get_request_body(json=omit, data=None, request_options=unrelated_request_options, omit=omit) + assert json_body is None + assert data_body is None + + json_body2, data_body2 = get_request_body( + json=None, data=omit, request_options=unrelated_request_options, omit=omit + ) + assert json_body2 is None + assert data_body2 is None + + # an explicitly passed body is untouched + json_body3, _ = get_request_body(json={"hello": "world"}, data=None, request_options=None, omit=omit) + assert json_body3 == {"hello": "world"} + + +def test_optional_body_sends_no_content_when_every_property_is_omitted() -> None: + """An endpoint that inlines an omittable body sends nothing once every property is omitted. + + The body reaches the client as a dict of sentinels rather than as a single argument, so + ``optional_body`` is what tells the client that an empty result means "no body at all". + """ + omit = cast(Any, ...) + + json_body, data_body = get_request_body( + json={"amount": omit, "source": omit}, data=None, request_options=None, omit=omit, optional_body=True + ) + assert json_body is None + assert data_body is None + + # a body the API requires still goes out as `{}` + required_json_body, _ = get_request_body( + json={"amount": omit, "source": omit}, data=None, request_options=None, omit=omit + ) + assert required_json_body == {} + + # a property the caller did pass keeps the body + populated_json_body, _ = get_request_body( + json={"amount": 1, "source": omit}, data=None, request_options=None, omit=omit, optional_body=True + ) + assert populated_json_body == {"amount": 1} + + # additional body parameters keep the body, since the caller asked for them + with_additional_body_parameters, _ = get_request_body( + json={"amount": omit}, + data=None, + request_options={"additional_body_parameters": {"custom": "value"}}, + omit=omit, + optional_body=True, + ) + assert with_additional_body_parameters == {"custom": "value"} + + def test_json_body_preserves_none_values() -> None: """Test that JSON bodies preserve None values (they become JSON null).""" json_body, data_body = get_request_body( @@ -345,8 +409,8 @@ def test_sync_retries_on_connect_error(mock_sleep: MagicMock) -> None: @patch("deepgram.core.http_client.time.sleep", return_value=None) -def test_sync_retries_on_remote_protocol_error(mock_sleep: MagicMock) -> None: - """Sync: connection error retries on httpx.RemoteProtocolError.""" +def test_sync_opt_in_retries_on_remote_protocol_error(mock_sleep: MagicMock) -> None: + """Sync: ambiguous disconnect retries require an explicit opt-in.""" mock_client = MagicMock() mock_client.request.side_effect = [ httpx.RemoteProtocolError("Remote end closed connection without response"), @@ -354,7 +418,7 @@ def test_sync_retries_on_remote_protocol_error(mock_sleep: MagicMock) -> None: ] http_client = _make_sync_http_client(mock_client) - response = http_client.request(path="/test", method="GET") + response = http_client.request(path="/test", method="POST", request_options={"retry_remote_protocol_errors": True}) assert response.status_code == 200 assert mock_client.request.call_count == 2 @@ -421,8 +485,8 @@ async def test_async_retries_on_connect_error(mock_sleep: AsyncMock) -> None: @pytest.mark.asyncio @patch("deepgram.core.http_client.asyncio.sleep", new_callable=AsyncMock) -async def test_async_retries_on_remote_protocol_error(mock_sleep: AsyncMock) -> None: - """Async: connection error retries on httpx.RemoteProtocolError.""" +async def test_async_opt_in_retries_on_remote_protocol_error(mock_sleep: AsyncMock) -> None: + """Async: ambiguous disconnect retries require an explicit opt-in.""" mock_client = MagicMock() mock_client.request = AsyncMock( side_effect=[ @@ -432,7 +496,9 @@ async def test_async_retries_on_remote_protocol_error(mock_sleep: AsyncMock) -> ) http_client = _make_async_http_client(mock_client) - response = await http_client.request(path="/test", method="GET") + response = await http_client.request( + path="/test", method="POST", request_options={"retry_remote_protocol_errors": True} + ) assert response.status_code == 200 assert mock_client.request.call_count == 2 @@ -672,28 +738,20 @@ def _make_response(status_code: int) -> httpx.Response: return httpx.Response(status_code=status_code, content=b"") -@pytest.mark.parametrize( - "status_code", - [408, 409, 429, 500, 501, 502, 503, 504, 599], -) +RETRYABLE_STATUS_CODES = [408, 409, 429, 500, 501, 502, 503, 504, 599] +NON_RETRYABLE_STATUS_CODES = [200, 201, 301, 400, 401, 403, 404] + + +@pytest.mark.parametrize("status_code", RETRYABLE_STATUS_CODES) def test_should_retry_retryable_status_codes(status_code: int) -> None: - """Legacy mode: retries on 408, 409, 429, and all >= 500.""" assert _should_retry(_make_response(status_code)) is True -@pytest.mark.parametrize( - "status_code", - [200, 201, 301, 400, 401, 403, 404], -) +@pytest.mark.parametrize("status_code", NON_RETRYABLE_STATUS_CODES) def test_should_not_retry_non_retryable_status_codes(status_code: int) -> None: assert _should_retry(_make_response(status_code)) is False -def test_should_retry_599_upper_boundary() -> None: - """Legacy mode retries on >= 500, which includes 599.""" - assert _should_retry(_make_response(599)) is True - - # --------------------------------------------------------------------------- # RequestOptions timeout resolution tests (timeout / deprecated timeout_in_seconds) # --------------------------------------------------------------------------- @@ -759,3 +817,66 @@ async def test_async_request_options_timeout_takes_precedence() -> None: ) await http_client.request(path="/test", method="GET", request_options={"timeout": 30, "timeout_in_seconds": 45}) assert dummy_client.last_request_kwargs["timeout"] == 30 + + +def test_drop_content_type_without_body_omits_header_for_bodyless_optional_call() -> None: + """An optional-body endpoint the caller left empty must not advertise a media type. + + `get_request_body` drops the body, but the endpoint still hands over the content type it + would have used, so a server that branches on the header would see a JSON request carrying + nothing at all. + """ + headers = {"content-type": "application/json", "authorization": "Bearer x"} + + assert drop_content_type_without_body(headers, json_body=None, data_body=None, optional_body=True) == { + "authorization": "Bearer x" + } + + +def test_drop_content_type_without_body_keeps_header_when_a_body_is_sent() -> None: + headers = {"content-type": "application/json"} + + assert ( + drop_content_type_without_body(headers, json_body={"amount": 60}, data_body=None, optional_body=True) == headers + ) + assert drop_content_type_without_body(headers, json_body=None, data_body="raw", optional_body=True) == headers + + +def test_drop_content_type_without_body_leaves_required_body_endpoints_alone() -> None: + """Without the opt-in, an endpoint keeps the header it has always sent.""" + headers = {"Content-Type": "application/json"} + + assert drop_content_type_without_body(headers, json_body=None, data_body=None, optional_body=False) == headers + + +def test_merge_headers_replaces_case_variants() -> None: + merged = _merge_headers( + {"PLAID-SECRET": "base", "X-Other": "1"}, + {"plaid-secret": "endpoint"}, + None, + {"Plaid-Secret": "override"}, + ) + assert merged == {"X-Other": "1", "Plaid-Secret": "override"} + + +def test_redact_headers_is_case_insensitive() -> None: + redacted = _redact_headers({"AUTHORIZATION": "Bearer t", "X-Api-Key": "k", "Accept": "application/json"}) + assert redacted == {"AUTHORIZATION": "[REDACTED]", "X-Api-Key": "[REDACTED]", "Accept": "application/json"} + + +def test_http_client_sends_single_header_for_case_variant_override() -> None: + dummy_client = _DummySyncClient() + http_client = HttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {"PLAID-SECRET": "base"}, + base_url=lambda: "https://example.com", + ) + http_client.request( + path="resource", + method="GET", + request_options={"additional_headers": {"plaid-secret": "override"}}, + ) + sent = dummy_client.last_request_kwargs["headers"] + assert [k for k in sent if k.lower() == "plaid-secret"] == ["plaid-secret"] + assert sent["plaid-secret"] == "override" diff --git a/wiremock/wiremock-mappings.json b/wiremock/wiremock-mappings.json index dfb887ec..7e097cee 100644 --- a/wiremock/wiremock-mappings.json +++ b/wiremock/wiremock-mappings.json @@ -1778,9 +1778,9 @@ }, "response": { "status": 200, - "body": "{\n \"key\": \"value\"\n}", + "base64Body": "JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSA+PgplbmRvYmoKeHJlZgowIDQKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDE1IDAwMDAwIG4gCjAwMDAwMDAwNjQgMDAwMDAgbiAKMDAwMDAwMDEyMSAwMDAwMCBuIAp0cmFpbGVyCjw8IC9TaXplIDQgL1Jvb3QgMSAwIFIgPj4Kc3RhcnR4cmVmCjE5MgolJUVPRgo=", "headers": { - "Content-Type": "application/json" + "Content-Type": "application/octet-stream" } }, "uuid": "b06ec977-02ed-41e2-8fff-2bc45cd2166b", @@ -1814,9 +1814,9 @@ }, "response": { "status": 200, - "body": "{\n \"key\": \"value\"\n}", + "base64Body": "JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSA+PgplbmRvYmoKeHJlZgowIDQKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDE1IDAwMDAwIG4gCjAwMDAwMDAwNjQgMDAwMDAgbiAKMDAwMDAwMDEyMSAwMDAwMCBuIAp0cmFpbGVyCjw8IC9TaXplIDQgL1Jvb3QgMSAwIFIgPj4Kc3RhcnR4cmVmCjE5MgolJUVPRgo=", "headers": { - "Content-Type": "application/json" + "Content-Type": "application/octet-stream" } }, "uuid": "03be31ca-f645-45f5-80a1-a0b4fb7de41a",