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
3 changes: 3 additions & 0 deletions google/genai/_gaos/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ async def run_sync_in_thread(func: Callable[..., _T], *args) -> _T:
from .security import get_security, get_security_from_env

from .serializers import (
ALLOW_UNKNOWN_UNION_VARIANTS,
get_pydantic_model,
marshal_json,
unmarshal,
Expand Down Expand Up @@ -125,6 +126,7 @@ async def run_sync_in_thread(func: Callable[..., _T], *args) -> _T:
"stream_to_bytes",
"stream_to_bytes_async",
"template_url",
"ALLOW_UNKNOWN_UNION_VARIANTS",
"unmarshal",
"unmarshal_json",
"validate_decimal",
Expand All @@ -147,6 +149,7 @@ async def run_sync_in_thread(func: Callable[..., _T], *args) -> _T:
"parse_duration": ".datetimes",
"get_global_from_env": ".values",
"get_headers": ".headers",
"ALLOW_UNKNOWN_UNION_VARIANTS": ".serializers",
"get_pydantic_model": ".serializers",
"get_query_params": ".queryparams",
"get_response_headers": ".headers",
Expand Down
5 changes: 4 additions & 1 deletion google/genai/_gaos/utils/response_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
from .._version import __response_mode_header__
from .._hooks.types import AfterParseErrorContext
from .eventstreaming import Stream, AsyncStream
from .serializers import ALLOW_UNKNOWN_UNION_VARIANTS
from .unmarshal_json_response import unmarshal_json_response

P = ParamSpec("P")
Expand Down Expand Up @@ -207,7 +208,9 @@ def _synthesized_decoder(raw: str, _t: Any = chunk_t) -> Any:
raise ValueError(
f"Synthesized SSE decoder expected an envelope of shape {{'data': ...}}, got {envelope!r}. Pass decoder=<fn> to parse(...) to handle non-standard envelopes."
)
return _t.model_validate(envelope["data"])
return _t.model_validate(
envelope["data"], context={ALLOW_UNKNOWN_UNION_VARIANTS: True}
)

resolved_decoder = _synthesized_decoder

Expand Down
45 changes: 24 additions & 21 deletions google/genai/_gaos/utils/retries.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,6 @@

import httpx

try:
import httpx2
except ImportError:
httpx2 = None

_RETRY_EXCEPTIONS = (
(httpx.NetworkError, httpx.TimeoutException)
if httpx2 is None
else (httpx.NetworkError, httpx.TimeoutException, httpx2.NetworkError, httpx2.TimeoutException)
)


class BackoffStrategy:
"""Exponential backoff strategy configuration."""
Expand Down Expand Up @@ -142,6 +131,18 @@ def __init__(self, inner: Exception):
self.inner = inner


_TRANSPORT_ERROR_NAMES = frozenset({"NetworkError", "TimeoutException"})
_TRANSPORT_ERROR_BASES = frozenset({"TransportError", "RequestError", "HTTPError"})


def _is_transport_error(exception: BaseException) -> bool:
"""Report whether an exception is a connection or timeout failure."""
if isinstance(exception, (httpx.NetworkError, httpx.TimeoutException)):
return True
names = {base.__name__ for base in type(exception).__mro__}
return bool(names & _TRANSPORT_ERROR_NAMES) and _TRANSPORT_ERROR_BASES <= names


def _parse_retry_after_header(response: httpx.Response) -> Optional[int]:
"""Parse Retry-After header from response.

Expand Down Expand Up @@ -248,14 +249,15 @@ def do_request(attempt: int) -> httpx.Response:

if should_retry:
raise TemporaryError(res)
except _RETRY_EXCEPTIONS as exception:
if retries.config.retry_connection_errors:
raise

raise PermanentError(exception) from exception
except TemporaryError:
raise
except Exception as exception:
if (
_is_transport_error(exception)
and retries.config.retry_connection_errors
):
raise

raise PermanentError(exception) from exception

return res
Expand Down Expand Up @@ -308,14 +310,15 @@ async def do_request(attempt: int) -> httpx.Response:

if should_retry:
raise TemporaryError(res)
except _RETRY_EXCEPTIONS as exception:
if retries.config.retry_connection_errors:
raise

raise PermanentError(exception) from exception
except TemporaryError:
raise
except Exception as exception:
if (
_is_transport_error(exception)
and retries.config.retry_connection_errors
):
raise

raise PermanentError(exception) from exception

return res
Expand Down
30 changes: 26 additions & 4 deletions google/genai/_gaos/utils/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,26 @@ def validate(c):
return validate


ALLOW_UNKNOWN_UNION_VARIANTS = "speakeasy_allow_unknown_union_variants"
"""Validation-context key enabling the Unknown fallback on open discriminated
unions. The SDK sets it when deserializing server responses; validation
without it (e.g. of user-constructed request payloads) stays strict. Pass
``context={ALLOW_UNKNOWN_UNION_VARIANTS: True}`` to ``model_validate`` to
opt in when parsing response payloads manually."""


def unmarshal_json(raw, typ: Any) -> Any:
return unmarshal(from_json(raw), typ, coerce_iterables=False)
return unmarshal(
from_json(raw), typ, coerce_iterables=False, allow_unknown_union_variants=True
)


def unmarshal(val, typ: Any, coerce_iterables: bool = True) -> Any:
def unmarshal(
val,
typ: Any,
coerce_iterables: bool = True,
allow_unknown_union_variants: bool = False,
) -> Any:
if coerce_iterables:
val = _coerce_iterables_for_type(val, typ)
unmarshaller = create_model(
Expand All @@ -142,7 +157,12 @@ def unmarshal(val, typ: Any, coerce_iterables: bool = True) -> Any:
__config__=ConfigDict(populate_by_name=True, arbitrary_types_allowed=True),
)

m = unmarshaller(body=val)
if allow_unknown_union_variants:
m = unmarshaller.model_validate(
{"body": val}, context={ALLOW_UNKNOWN_UNION_VARIANTS: True}
)
else:
m = unmarshaller(body=val)

# pyright: ignore[reportAttributeAccessIssue]
return m.body # type: ignore
Expand All @@ -153,7 +173,9 @@ def unmarshal(val, typ: Any, coerce_iterables: bool = True) -> Any:

def construct_unvalidated(value: Any, typ: Any, _depth: int = 0) -> Any:
try:
return unmarshal(value, typ, coerce_iterables=True)
return unmarshal(
value, typ, coerce_iterables=True, allow_unknown_union_variants=True
)
except Exception:
try:
return _construct_lenient(value, typ, _depth)
Expand Down
51 changes: 35 additions & 16 deletions google/genai/_gaos/utils/unions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@

"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT."""

from typing import Any
from typing import Any, Mapping

from pydantic import BaseModel, TypeAdapter, ValidationError
from .serializers import construct_unvalidated
from pydantic import BaseModel, TypeAdapter, ValidationError, ValidationInfo


def parse_open_union(
v: Any,
info: ValidationInfo,
*,
disc_key: str,
variants: dict[str, Any],
Expand All @@ -35,28 +35,47 @@ def parse_open_union(
"""Parse an open discriminated union value with forward-compatibility.

Known discriminator values are dispatched to their variant types.
Unknown discriminator values — or known discriminator values whose
payload fails variant validation (e.g. a partial variant emitted by a
newer server) — produce an instance of the fallback class, preserving
the raw payload for inspection.

The Unknown fallback only applies when the validation context carries
ALLOW_UNKNOWN_UNION_VARIANTS, which the SDK sets when deserializing
server responses. There, unknown discriminator values — or known
discriminator values whose payload fails variant validation (e.g. a
partial variant emitted by a newer server) — produce an instance of the
fallback class, preserving the raw payload for inspection. Without the
flag (e.g. user-constructed request payloads), invalid values raise so
mistakes surface locally instead of being sent to the server.

Non-dict values and dicts missing the discriminator deliberately raise
instead of falling back, so pydantic can try sibling branches of an
enclosing union (e.g. None in Optional[...]).
"""
# pylint: disable=import-outside-toplevel
from .serializers import ALLOW_UNKNOWN_UNION_VARIANTS

if isinstance(v, BaseModel):
return v
if not isinstance(v, dict) or disc_key not in v:
raise ValueError(f"{union_name}: expected object with '{disc_key}' field")
context = info.context
fallback_allowed = isinstance(context, Mapping) and bool(
context.get(ALLOW_UNKNOWN_UNION_VARIANTS)
)
disc = v[disc_key]
variant_cls = variants.get(disc)
if variant_cls is not None:
try:
if isinstance(variant_cls, type) and issubclass(variant_cls, BaseModel):
return variant_cls.model_validate(v)
return TypeAdapter(variant_cls).validate_python(v)
except ValidationError:
if lenient:
return construct_unvalidated(v, variant_cls)
if variant_cls is None:
if fallback_allowed:
return unknown_cls(raw=v)
return unknown_cls(raw=v)
raise ValueError(f"{union_name}: unrecognized {disc_key} value {disc!r}")
try:
if isinstance(variant_cls, type) and issubclass(variant_cls, BaseModel):
return variant_cls.model_validate(v, context=info.context)
return TypeAdapter(variant_cls).validate_python(v, context=info.context)
except ValidationError:
if not fallback_allowed:
raise
if lenient:
# pylint: disable=import-outside-toplevel
from .serializers import construct_unvalidated

return construct_unvalidated(v, variant_cls)
return unknown_cls(raw=v)
Loading