This is an automated email from the ASF dual-hosted git repository.

kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 736c349cdca Fingerprint durable agent steps that hold sets or values 
that are not JSON (#71575)
736c349cdca is described below

commit 736c349cdca4018eb3a1ac787543eb3fa064beda
Author: Yash jain <[email protected]>
AuthorDate: Fri Oct 16 06:33:51 2026 +0530

    Fingerprint durable agent steps that hold sets or values that are not JSON 
(#71575)
    
    
    Plain JSON tool arguments and settings hash exactly as before, and the
    message history and request parameters are still hashed from pydantic's
    json-mode dump, so stored fingerprints keep matching. Other values go
    through to_jsonable_python, so datetime, Decimal, dataclasses, bytes and
    non-string dict keys fingerprint instead of coming back as None.
    
    The json rendering is paired with pydantic's python-mode dump of the same
    payload, which has the same shape but keeps sets as sets and wraps an
    iterator instead of reading it. A list is sorted where the dump holds a
    set, since a set of strings otherwise hashes differently in every process.
    Dict keys that collide once rendered, and anything that renders through an
    iterator, are refused rather than hashed. Tool arguments are rendered from
    deep copies, so fingerprinting cannot change what the tool receives.
    
    Whatever rendering raises costs the step a live re-run, never the task,
    and both warnings name the step, the error and its type.
    
    * Do not replay or cache AI steps that cannot be verified
    
    A request that could not be fingerprinted came back as None, and None
    equalled the None stored with the entry, so the replay guard passed for
    exactly the requests it could not verify. The guard now requires a real
    fingerprint, and such a step is no longer written to the cache, since an
    entry without a fingerprint could never be replayed. It counts as skipped,
    so the end-of-run warning names it, and a usable entry from an earlier
    attempt survives instead of being overwritten.
    
    * Document what the durable fingerprint covers and refuses
    
    Says which values fingerprint, what is refused and why, what changes for
    entries cached by an earlier version, and when a step that cannot be
    fingerprinted costs the rest of the run.
    
    * Sort a set only where its rendering is found
    
    A list was sorted wherever the python-mode dump had a set at the same
    place, which a serializer that applies only in JSON mode could defeat by
    putting another list there. A list is now sorted only where it holds the
    members of that set, rendered on their own. A set that cannot be found is
    refused when its order follows the hash seed, and kept as rendered when it
    does not, such as a set of integers.
    
    Non-string dict keys are rendered on their own, so a serializer that drops
    or reorders entries is no longer mistaken for colliding keys, or paired
    with a neighbouring value.
    
    A set of models or dataclass instances, or a dict keyed by one, has no
    python-mode dump. The message history and request parameters then hash
    from the JSON rendering alone, as they did before.
    
    The hash-seed tests share one snippet run under two seeds.
    
    * Order sets holding NaN and refuse bytes keys that collide in a model
    
    A NaN hashes by its identity, so a set holding one iterated differently in
    every run, and the message dump renders it as null where the set's own
    rendering kept it, so the set was never found and was hashed in that order.
    The set's own rendering now also tries the null mode, and a NaN no longer
    counts as seed-free.
    
    Inside a model, b"a" and "a" render as one key, which rendering the keys on
    their own did not show, so two different payloads could share a digest. A
    dict with a bytes key that renders fewer keys is now refused, as one with a
    nan or inf key already was.
    
    The docs now say that a set behind a JSON-only serializer that renames or
    reshapes it is refused even when that serializer sorts it, that a dict keyed
    by a model is only refused from pydantic 2.13, and that a value python mode
    cannot dump turns off set ordering for the whole history.
    
    Tests pin the template branches for enum members, tuples holding sets, dict
    members and the empty set, and add those set shapes to the cross-seed run.
    The two pairs of caching tests that differed only in whether an entry
    already existed are now one parametrized test each.
    
    ---------
    
    Co-authored-by: Kaxil Naik <[email protected]>
---
 providers/common/ai/docs/durable_execution.rst     |   49 +
 .../providers/common/ai/durable/fingerprint.py     |  473 +++++-
 .../unit/common/ai/durable/test_capability.py      |   58 +
 .../unit/common/ai/durable/test_fingerprint.py     | 1628 +++++++++++++++++++-
 4 files changed, 2173 insertions(+), 35 deletions(-)

diff --git a/providers/common/ai/docs/durable_execution.rst 
b/providers/common/ai/docs/durable_execution.rst
index 2d96bba3da2..53d09702d74 100644
--- a/providers/common/ai/docs/durable_execution.rst
+++ b/providers/common/ai/docs/durable_execution.rst
@@ -193,6 +193,55 @@ In a plain ``@task``, which has no such moment, the 
capability deletes a run's j
 as soon as that run succeeds: after a failure, only the run that failed 
replays, and
 the agents that ran before it run again.
 
+Values that are not plain JSON
+------------------------------
+
+Plain JSON tool arguments and settings are hashed as they are. Anything else is
+fingerprinted from pydantic's JSON rendering, so ordinary types that are not 
JSON, such
+as a ``datetime`` or ``Decimal`` tool argument, a dataclass in 
``tool_choice``, the
+bytes in a ``BinaryContent`` or a dict keyed by date, fingerprint normally. 
Bytes in
+tool arguments and settings are rendered as base64, so binary data that is not 
valid
+UTF-8 fingerprints too; bytes inside a pydantic model follow that model's
+``ser_json_bytes`` setting instead, which renders them as UTF-8 text by 
default. Because
+a tool call is fingerprinted from how its arguments render, a field excluded 
from
+serialization and a secret value, which renders masked, take no part in it.
+
+The members of a ``set`` are ordered before hashing. A set of strings iterates 
in an
+order that changes from one process to the next, and every attempt is a new 
process, so
+without this a step holding one would never match on retry. A list that a 
serializer
+produces is hashed as the serializer produced it, so a set that a serializer 
turns into
+a list may not match on retry. The one list that is re-sorted is the output of 
a
+JSON-only serializer (``when_used="json"``) that returns the members of a set: 
it cannot
+be told from the set itself, so ``["z-first", "a"]`` hashes as ``["a", 
"z-first"]``.
+
+A request cannot be fingerprinted when:
+
+- two distinct dict keys render alike, such as ``1`` and ``"1"``, since the 
fingerprint
+  could then not tell those payloads apart;
+- a set whose order changes between processes cannot be found in the 
rendering, because
+  its members have different shapes, or because a JSON-only serializer renamed 
or
+  reshaped it, even one that also sorts it;
+- a value would render through an iterator, since rendering would consume it. A
+  parameter annotated ``Iterable[...]`` is the usual case: pydantic validates 
it lazily,
+  and reading it to hash it would consume input the tool has not read yet. Tool
+  arguments are rendered from copies, so fingerprinting never changes what the 
tool
+  receives, and an argument that cannot be copied is not fingerprinted either;
+- pydantic cannot render a value at all.
+
+Such a step is handled like a step with no request to fingerprint: it replays 
on its
+name, and only when the step before it replayed. The task log names it with a
+``could not fingerprint model request`` or ``could not fingerprint tool call`` 
warning.
+On the model path the cause is rarely confined to one step. ``model_settings`` 
and the
+tool definitions travel with every request, and a value that enters the message
+history, for example in a tool return, is carried into every later request, so 
every
+model step from that point on is replayed without verification.
+
+A set of models or dataclass instances, and from pydantic 2.13 a dict keyed by 
a model
+or dataclass instance, is a special case. In a tool argument or a setting it 
cannot be
+fingerprinted. In the message history it is rendered, and the history is then 
hashed
+from its JSON rendering alone: nothing in it is ordered and nothing is 
refused, so a set
+of strings elsewhere in that history can make the run diverge at that step on 
retry.
+
 .. _durable-code-mode:
 
 Code mode
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/durable/fingerprint.py 
b/providers/common/ai/src/airflow/providers/common/ai/durable/fingerprint.py
index 48e717501e3..bf001af966e 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/durable/fingerprint.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/durable/fingerprint.py
@@ -32,31 +32,88 @@ fingerprint, so stale tool results recorded under the old 
conversation no
 longer match.
 
 Fields that pydantic-ai regenerates on every attempt (message-level
-``timestamp``/``run_id``/``conversation_id``, part-level ``timestamp``, the
-``capability_id`` of a tool from a capability without an ``id``, the 
timestamps of
-the nested calls code mode records on a ``run_code`` return) are excluded
-from the fingerprint, and set-valued request
-parameters are sorted or dropped.  Registered secrets in the message history 
are
-masked before hashing, as they are in the tool results the journal records.  
Requests that cannot be serialized to JSON
-fingerprint as ``None``, which degrades that step to unverified positional
-replay (the pre-fingerprint behavior) rather than disabling caching.
+``timestamp``/``run_id``/``conversation_id``, part-level ``timestamp``, and the
+timestamps of the nested calls code mode records on a ``run_code`` return) are
+excluded from the fingerprint, and registered secrets in the message history 
are
+masked before hashing, as they are in the tool results the journal records.
+Plain JSON tool arguments and settings are hashed exactly as before.  
Everything
+else is rendered by pydantic: the message history and the request parameters by
+their json-mode dump, which is also what they were always hashed from, and 
other
+tool arguments and settings by ``to_jsonable_python``, with bytes outside a 
model
+as base64.  So a value that is not a JSON type but renders the same way on 
every
+attempt -- a ``datetime`` or ``Decimal`` tool argument, a dataclass in
+``tool_choice`` -- still produces a usable fingerprint.
+The ``capability_id`` of a tool from a capability without an ``id`` is left out
+of the request parameters, since it is random per run, and so is
+``deferred_capability_ids`` (see ``_normalize_params``).
+
+That rendering is hashed as it is, with two exceptions.  Both are found by
+dumping the payload a second time in pydantic's python mode, which has the same
+shape as the JSON rendering but keeps sets as sets and dict keys as they are, 
and
+wraps an iterator instead of reading it (see ``_check_guide``).  The members 
of a
+set are sorted by their JSON encoding, because a set of strings iterates in an
+order that follows the interpreter's hash seed and every task attempt is a 
fresh
+process (see ``_order_sets``).  A list is sorted only where it holds exactly 
the
+members of the set found at its place, so a list that a serializer produced is
+left as the serializer produced it; a set that cannot be found in the rendering
+is refused if its order follows the hash seed, since the digest would not be
+reproduced on retry.  And a payload is refused rather than hashed if it would
+render through an iterator, since rendering consumes it, or if distinct dict 
keys
+render alike, such as ``1`` and ``"1"``, since two different payloads would 
then
+share a digest.  Sorting changes the digest of a set whose order was already
+stable, such as a set of integers, so a history holding one can re-run from 
that
+step once after upgrading.
+
+Python mode cannot dump a set of models or dataclass instances, because it 
turns
+them into dicts, which cannot be hashed; from pydantic 2.13 the same goes for a
+dict keyed by one.  The message history and the request parameters are then
+hashed from their JSON rendering alone, as they were before, with nothing 
sorted
+and nothing refused anywhere in them, so a set of strings elsewhere in the same
+history hashes in hash-seed order again.
+
+Tool arguments are rendered from deep copies, so nothing that rendering runs 
-- a
+computed field, a cached property, a serializer -- reaches the objects the 
tool is
+about to receive.  An argument that cannot be copied, such as a lazily 
validated
+``Iterable``, is refused.
+
+A request that cannot be fingerprinted fingerprints as ``None``.  The journal
+replays such a step without verification, and only when its name matches and 
the
+step before it replayed, so it is never matched against a different preceding
+conversation (see ``DurableRun.claim``).  On the model path this is seldom
+confined to one step, because model settings, the tool definitions and the
+message history are usually carried into every later request, so the model 
steps
+from that point on lose verification too.  A tool call is fingerprinted from 
its
+name, arguments and call id alone, so it cannot stop another step from being
+fingerprinted.
 """
 
 from __future__ import annotations
 
+import copy
+import datetime
+import enum
 import hashlib
+import itertools
 import json
-from typing import TYPE_CHECKING, Any
+import math
+import uuid
+from collections import deque
+from collections.abc import Iterator
+from decimal import Decimal
+from typing import TYPE_CHECKING, Any, Literal
 
 from pydantic import TypeAdapter
 from pydantic_ai.messages import ModelMessagesTypeAdapter
 from pydantic_ai.models import ModelRequestParameters
+from pydantic_core import to_jsonable_python
 
 from airflow.providers.common.ai.utils.masking import mask_secrets
 from airflow.providers.common.ai.utils.prompt_cache import 
PROMPT_CACHE_SETTING_NAMES
 from airflow.providers.common.ai.utils.task_logger import get_task_logger
 
 if TYPE_CHECKING:
+    from collections.abc import Iterable
+
     from pydantic_ai.messages import ModelMessage
     from pydantic_ai.settings import ModelSettings
 
@@ -64,18 +121,47 @@ log = get_task_logger()
 
 _MODEL_REQUEST_PARAMETERS_ADAPTER = TypeAdapter(ModelRequestParameters)
 
+# Renders tool arguments and settings in python mode, the way 
``to_jsonable_python``
+# renders them in JSON mode.
+_ANY_ADAPTER: TypeAdapter[Any] = TypeAdapter(Any)
+
 # Message-level fields regenerated on every attempt.
 _VOLATILE_MESSAGE_KEYS = ("timestamp", "run_id", "conversation_id")
 
 # Settings that control transport, not response content. Excluded from the
-# fingerprint: changing them should not invalidate a cached response, and some
-# (``timeout`` can be an ``httpx.Timeout``) are not JSON-serializable, which
-# would otherwise force the whole fingerprint to ``None`` and silently disable
-# replay verification for every step. Prompt cache settings only decide what 
the
-# provider keeps for the next request, so ``cache_prompt`` can change between
-# attempts without re-running the steps the previous one completed.
+# fingerprint: changing them should not invalidate a cached response, and
+# ``timeout`` can be an ``httpx.Timeout``, which neither ``json`` nor pydantic
+# can serialize.
+# Prompt cache settings only decide what the provider keeps for the next
+# request, so ``cache_prompt`` can change between attempts without re-running
+# the steps the previous one completed.
+#
+# This frozenset is load-bearing. Model settings accompany every request, so 
one
+# member that pydantic cannot serialize fingerprints every model step as 
``None``
+# -- which costs durable execution entirely for the run (nothing is cached,
+# nothing is replayed), not merely the verification of a replay. Merely 
non-JSON
+# values are fine, since pydantic renders those; a setting pydantic cannot
+# serialize either has to be listed here instead.
 _TRANSPORT_ONLY_SETTINGS = frozenset({"timeout"}) | PROMPT_CACHE_SETTING_NAMES
 
+# Exact types that can hold nothing else, so the walks need not visit them.
+_LEAF_TYPES = frozenset({str, bytes, bytearray, bool, int, float, type(None)})
+
+# A set member with no set inside it: its rendering is taken as it is.
+_LEAF = "leaf"
+
+# Exact types whose hash does not follow the interpreter's hash seed, so a set 
of
+# them iterates in the same order in every process.
+_SEED_FREE_TYPES = frozenset({bool, int, float, complex, type(None), Decimal, 
datetime.timedelta, uuid.UUID})
+
+# Every way pydantic renders bytes as JSON. Bytes inside a model follow that 
model's
+# ``ser_json_bytes``, which the python-mode dump does not record.
+_BYTES_MODES: tuple[Literal["base64", "utf8", "hex"], ...] = ("base64", 
"utf8", "hex")
+
+# Every way pydantic renders ``nan`` and ``inf``: the message dump renders 
them as
+# ``None``, where ``to_jsonable_python`` keeps them by default.
+_INF_NAN_MODES: tuple[Literal["constants", "null"], ...] = ("constants", 
"null")
+
 
 def _content_settings(model_settings: ModelSettings | None) -> dict[str, Any] 
| None:
     """Return the content-affecting settings, or ``None`` if there are none."""
@@ -148,12 +234,288 @@ def _normalize_params(params_dump: dict[str, Any]) -> 
dict[str, Any]:
     return cleaned
 
 
+def _check_guide(guide: Any) -> bool:
+    """
+    Refuse an iterator, and report whether the JSON rendering needs 
``_order_sets``.
+
+    ``guide`` is pydantic's python-mode dump of the payload. It has the shape 
of the
+    json-mode rendering that is hashed -- the same keys, aliases, exclusions,
+    computed fields, extras and serializer output -- but keeps sets as sets 
and dict
+    keys as they are, and wraps an iterator in a ``SerializationIterator`` 
without
+    reading it. So this walk runs no user code, and nothing has been consumed 
yet.
+
+    Raises ``TypeError`` if the payload renders through an iterator anywhere.
+    Rendering it to JSON would consume it, and pydantic validates an 
``Iterable[T]``
+    tool parameter lazily into an iterator; a tool return that holds a 
generator
+    would reach the model empty. Returns ``True`` if the guide holds a set, or 
a dict
+    key that is not a string, since only those can make the JSON rendering 
depend on
+    the hash seed or lose a key.
+    """
+    needs_check = False
+    pending = [guide]
+    seen: set[int] = set()
+    while pending:
+        item = pending.pop()
+        if id(item) in seen:
+            # The dump shares this object, or left a cycle in place: walked 
already.
+            continue
+        seen.add(id(item))
+        children: Iterable[Any]
+        if isinstance(item, dict):
+            needs_check = needs_check or any(not isinstance(key, str) for key 
in item)
+            children = item.values()
+        elif isinstance(item, (list, tuple, deque)):
+            children = item
+        elif isinstance(item, (set, frozenset)):
+            needs_check = True
+            children = item
+        elif isinstance(item, Iterator):
+            raise TypeError(f"cannot fingerprint a value that renders through 
a {type(item).__name__}")
+        elif isinstance(item, enum.Enum):
+            # Python mode keeps an Enum member; JSON renders its value.
+            children = (item.value,)
+        else:
+            continue
+        pending.extend(child for child in children if type(child) not in 
_LEAF_TYPES)
+    return needs_check
+
+
+def _json_order(member: Any) -> str:
+    return json.dumps(member, sort_keys=True)
+
+
+def _template(value: Any) -> Any:
+    """
+    Say where the sets are inside one set member, or ``None`` if that cannot 
be said.
+
+    ``_LEAF`` for a member with no set inside it, ``("set", inner)`` for a set 
whose
+    members all have the template ``inner``, and ``("sequence", parts)`` for a 
tuple
+    with a set somewhere in it.
+    """
+    if isinstance(value, enum.Enum):
+        return _template(value.value)
+    if isinstance(value, (set, frozenset)):
+        inner = _member_template(value)
+        return None if inner is None else ("set", inner)
+    if isinstance(value, (list, tuple, deque)):
+        parts = tuple(_template(item) for item in value)
+        if None in parts:
+            return None
+        return _LEAF if all(part == _LEAF for part in parts) else ("sequence", 
parts)
+    if isinstance(value, dict):
+        # No set member dumps to a dict (python mode refuses a set of models), 
so
+        # nothing is known about one.
+        return None
+    return _LEAF
+
+
+def _member_template(members: Iterable[Any]) -> Any:
+    """Return the template every member of a set shares, or ``None`` if they 
differ."""
+    templates = {_template(member) for member in members}
+    if not templates:
+        return _LEAF
+    return templates.pop() if len(templates) == 1 else None
+
+
+def _apply_template(template: Any, rendered: Any) -> Any:
+    """Sort the lists that ``template`` puts a set at, in the rendering of one 
set member."""
+    if template == _LEAF or not isinstance(rendered, list):
+        return rendered
+    kind, inner = template
+    if kind == "set":
+        return sorted((_apply_template(inner, item) for item in rendered), 
key=_json_order)
+    if len(rendered) != len(inner):
+        return rendered
+    return [_apply_template(part, item) for part, item in zip(inner, rendered)]
+
+
+def _seed_free(member: Any) -> bool:
+    """Whether a set member lands in the same place in a set in every 
process."""
+    if type(member) in (tuple, frozenset):
+        return all(_seed_free(item) for item in member)
+    if type(member) not in _SEED_FREE_TYPES:
+        return False
+    # A NaN hashes by its identity, so where it lands changes from run to run.
+    return member == member
+
+
+def _unpaired(guide: Any, rendered: Any) -> Any:
+    """
+    Return ``rendered`` as it is, for a part of the guide that does not line 
up with it.
+
+    Raises ``TypeError`` if that part of the guide holds a set whose order 
follows
+    the hash seed. Its members are somewhere in ``rendered`` in that order, and
+    hashing them would record a digest the next attempt cannot reproduce: the 
run
+    would diverge at that step on every retry, where a step without a 
fingerprint
+    still replays right after a step that replayed. A set whose order is the 
same
+    in every process is kept as rendered, which is what was always hashed.
+    """
+    pending = [guide]
+    seen: set[int] = set()
+    while pending:
+        item = pending.pop()
+        if type(item) in _LEAF_TYPES or id(item) in seen:
+            continue
+        seen.add(id(item))
+        if isinstance(item, enum.Enum):
+            pending.append(item.value)
+        elif isinstance(item, dict):
+            pending.extend(item.values())
+        elif isinstance(item, (list, tuple, deque)):
+            pending.extend(item)
+        elif isinstance(item, (set, frozenset)):
+            if len(item) > 1 and not all(_seed_free(member) for member in 
item):
+                raise TypeError("cannot fingerprint a set that cannot be found 
in its JSON rendering")
+    return rendered
+
+
+def _order_set(guide: set[Any] | frozenset[Any], rendered: Any) -> Any:
+    """
+    Return ``rendered`` sorted, if it is the list of the members of the set 
``guide``.
+
+    That both dumps have a list and a set at the same place does not prove one 
came
+    from the other: a serializer that applies only in JSON mode can put any 
list
+    there, and sorting it would let two different payloads share a digest. So 
the
+    set is rendered on its own, and ``rendered`` is sorted only where both 
give the
+    same members once ordered. A set's members cannot pair up by position -- 
the
+    dump builds a new set, which may iterate in another order -- so the 
members'
+    shared template says where any set inside one of them sits (see 
``_template``).
+    """
+    template = _member_template(guide)
+    if template is None or not isinstance(rendered, list):
+        return _unpaired(guide, rendered)
+    ordered = sorted((_apply_template(template, item) for item in rendered), 
key=_json_order)
+    encoded = [_json_order(member) for member in ordered]
+    for bytes_mode, inf_nan_mode in itertools.product(_BYTES_MODES, 
_INF_NAN_MODES):
+        try:
+            own = to_jsonable_python(guide, bytes_mode=bytes_mode, 
inf_nan_mode=inf_nan_mode)
+        except ValueError:
+            # Bytes that are not UTF-8 cannot have been rendered as UTF-8.
+            continue
+        if sorted(_json_order(_apply_template(template, member)) for member in 
own) == encoded:
+            return ordered
+    return _unpaired(guide, rendered)
+
+
+def _order_sets(guide: Any, rendered: Any) -> Any:
+    """
+    Return ``rendered`` with every list that pydantic rendered from a set 
sorted.
+
+    ``rendered`` is the json-mode rendering that is hashed, and ``guide`` the
+    python-mode dump of the same payload (see ``_check_guide``). They are 
walked
+    side by side: a dict or a sequence pairs up by position, because both dumps
+    keep the same order, and a list is sorted where the guide holds a set with
+    those members (see ``_order_set``). Only a serializer that applies in JSON 
mode
+    alone can make the two differ, and what does not line up is kept exactly as
+    rendered or refused (see ``_unpaired``), never reordered.
+
+    Raises ``TypeError`` where distinct dict keys render alike, such as ``1`` 
and
+    ``"1"``: hashing the rendering would let two different payloads share a 
digest.
+    The keys are rendered on their own to tell that apart from a serializer 
that
+    drops, renames or reorders entries, whose dict is kept as rendered. 
Rendered
+    on their own, keys cannot show the two collisions that depend on where the
+    dict is: ``None`` and ``nan`` collide only in a message dump, and ``b"a"`` 
and
+    ``"a"`` only inside a model, which renders bytes as UTF-8. So a dict with a
+    ``nan``, ``inf`` or bytes key that renders fewer keys is refused as well.
+    """
+    if isinstance(guide, enum.Enum):
+        # An Enum member renders as its value.
+        return _order_sets(guide.value, rendered)
+    if isinstance(guide, (set, frozenset)):
+        return _order_set(guide, rendered)
+    if isinstance(guide, dict):
+        if not isinstance(rendered, dict):
+            return _unpaired(guide, rendered)
+        keys = list(guide)
+        if any(not isinstance(key, str) for key in keys):
+            try:
+                keys = list(to_jsonable_python(dict.fromkeys(keys), 
bytes_mode="base64"))
+            except ValueError:
+                # Keys only the enclosing schema can render, such as a 
``PurePath``:
+                # nothing says which entries the rendering kept.
+                return _unpaired(guide, rendered)
+            # The message dump renders ``nan`` and ``inf`` keys as ``None``, 
and a model
+            # renders bytes keys as UTF-8, which rendering the keys on their 
own does not show.
+            unsure = len(rendered) < len(guide) and any(
+                isinstance(key, (bytes, bytearray)) or (isinstance(key, float) 
and not math.isfinite(key))
+                for key in guide
+            )
+            if len(keys) < len(guide) or unsure:
+                raise TypeError("dict keys collide once rendered as JSON")
+        if keys != list(rendered):
+            # Not the entries the guide has, or not in its order: a value 
would be
+            # paired with another entry's rendering.
+            return _unpaired(guide, rendered)
+        return {
+            key: _order_sets(guide_item, item)
+            for guide_item, (key, item) in zip(guide.values(), 
rendered.items())
+        }
+    if isinstance(guide, (list, tuple, deque)):
+        if not isinstance(rendered, list) or len(rendered) != len(guide):
+            return _unpaired(guide, rendered)
+        return [_order_sets(guide_item, item) for guide_item, item in 
zip(guide, rendered)]
+    return rendered
+
+
+def _guide(adapter: TypeAdapter[Any], payload: Any) -> tuple[Any, bool]:
+    """
+    Dump the message history or the request parameters in python mode, and 
check the dump.
+
+    Returns the guide and whether the JSON rendering needs ``_order_sets``. 
Python
+    mode dumps a model or a dataclass to a dict even where it is a member of a 
set
+    (from pydantic 2.13, also where it is a dict key), which fails with
+    ``TypeError: unhashable type: 'dict'`` although JSON mode renders the same
+    value. There is then no guide: the JSON rendering is hashed as it is, 
exactly
+    as before, so nothing in it is sorted and an iterator in it is read rather
+    than refused.
+    """
+    try:
+        guide = adapter.dump_python(payload)
+    except TypeError:
+        return None, False
+    return guide, _check_guide(guide)
+
+
+def _render(value: Any, *, copy_first: bool = False) -> Any:
+    """
+    Render tool arguments or settings for hashing.
+
+    Plain JSON is returned unchanged, so it is hashed exactly as before: 
numeric keys
+    in numeric order, ``None`` and ``inf`` keys as ``null`` and ``Infinity``, 
and
+    nesting as deep as ``json`` allows. Anything else is rendered by
+    ``to_jsonable_python``, with set members in a fixed order and bytes as 
base64,
+    which also takes data that is not UTF-8; bytes inside a model follow that
+    model's own ``ser_json_bytes``. A value pydantic cannot serialize raises
+    ``PydanticSerializationError`` (a ``ValueError``) naming its type, so the 
caller
+    degrades to an unverifiable ``None`` rather than hashing a process-local 
repr
+    like ``<object at 0x...>`` that would never match on retry. A set of models
+    raises ``TypeError``, and from pydantic 2.13 so does a model used as a 
dict key:
+    python mode dumps them to dicts, which cannot be hashed.
+
+    With ``copy_first``, each rendering is taken from a deep copy of 
``value``, so the
+    objects themselves are left exactly as they were: rendering can run a 
computed
+    field, fill a cached property, or read an iterator a computed field holds. 
A
+    value that cannot be copied, such as a lazily validated iterator or a 
generator,
+    raises ``TypeError`` before anything is rendered.
+    """
+    try:
+        json.dumps(value, sort_keys=True)
+    except (TypeError, ValueError, RecursionError):
+        pass
+    else:
+        return value
+    guide_source, rendered_source = (
+        (copy.deepcopy(value), copy.deepcopy(value)) if copy_first else 
(value, value)
+    )
+    guide = _ANY_ADAPTER.dump_python(guide_source, by_alias=True)
+    needs_check = _check_guide(guide)
+    rendered = to_jsonable_python(rendered_source, by_alias=True, 
bytes_mode="base64")
+    return _order_sets(guide, rendered) if needs_check else rendered
+
+
 def _digest(payload: Any) -> str:
-    # No ``default=`` fallback: a non-JSON-serializable value must raise so the
-    # callers degrade to an unverifiable (None) fingerprint instead of hashing
-    # process-local reprs like ``<object at 0x...>`` that never match on retry.
-    canonical = json.dumps(payload, sort_keys=True)
-    return hashlib.sha256(canonical.encode()).hexdigest()
+    """Hash an already rendered payload."""
+    return hashlib.sha256(json.dumps(payload, 
sort_keys=True).encode()).hexdigest()
 
 
 def fingerprint_model_request(
@@ -161,6 +523,8 @@ def fingerprint_model_request(
     messages: list[ModelMessage],
     model_settings: ModelSettings | None,
     model_request_parameters: ModelRequestParameters,
+    *,
+    step: int | None = None,
 ) -> str | None:
     """
     Fingerprint a model request: model identity, message history, settings, 
and request parameters.
@@ -170,13 +534,27 @@ def fingerprint_model_request(
     model invalidates the cached response; only each tool's ``capability_id`` 
and
     ``deferred_capability_ids`` are left out.
 
-    Returns ``None`` when the request cannot be serialized; ``None`` compares
-    equal to ``None``, so requests that cannot be fingerprinted degrade to
-    unverified positional replay rather than disabling caching.
+    Returns ``None`` when the request cannot be fingerprinted. The journal then
+    replays the step without verification, and only right after a step that
+    replayed. Model settings, the tool definitions in the request parameters 
and
+    the message history are normally carried into every later request, so such 
a
+    value in any of them usually degrades every subsequent model step of the 
run
+    the same way. ``step`` is attached to the warning so the log names where it
+    began.
     """
     try:
+        # Messages and parameters are hashed from pydantic's json-mode dump, 
as they
+        # always were, so stored fingerprints still match. The python-mode 
dumps are
+        # taken first, since they refuse an iterator before the json-mode dump 
reads
+        # it, and they guide ``_order_sets`` where a set or a non-string key 
needs it.
+        messages_guide, messages_need_check = _guide(ModelMessagesTypeAdapter, 
messages)
+        params_guide, params_need_check = 
_guide(_MODEL_REQUEST_PARAMETERS_ADAPTER, model_request_parameters)
         dumped = ModelMessagesTypeAdapter.dump_python(messages, mode="json")
         params = 
_MODEL_REQUEST_PARAMETERS_ADAPTER.dump_python(model_request_parameters, 
mode="json")
+        if messages_need_check:
+            dumped = _order_sets(messages_guide, dumped)
+        if params_need_check:
+            params = _order_sets(params_guide, params)
         return _digest(
             {
                 "model": model_identifier,
@@ -185,33 +563,62 @@ def fingerprint_model_request(
                 # history can hold ``***`` where the live one held the secret. 
Masking the
                 # history here as well makes both match.
                 "messages": _strip_volatile(mask_secrets(dumped)),
-                "settings": _content_settings(model_settings),
+                "settings": _render(_content_settings(model_settings)),
                 "params": _normalize_params(params),
             }
         )
-    except (TypeError, ValueError):
-        # TypeError from json.dumps, ValueError covers 
PydanticSerializationError
+    except Exception as exc:
+        # Rendering runs user code -- computed fields, serializers, custom 
mappings --
+        # so anything can be raised here, and a fingerprint that cannot be 
computed
+        # must cost this step its verification, never the task. For a value 
pydantic
+        # cannot serialize, the error names its type, the only pointer to the
+        # setting or message at fault.
         log.warning(
-            "Durable: could not fingerprint model request; cached responses 
for this "
-            "step replay without verification"
+            "Durable: could not fingerprint model request; on retry this step 
replays without "
+            "verification, and only right after a step that replayed. If the 
cause is in model "
+            "settings, tool definitions or message history, later model steps 
of this run are "
+            "usually affected too",
+            step=step,
+            error=str(exc),
+            error_type=type(exc).__name__,
         )
         return None
 
 
-def fingerprint_tool_call(name: str, tool_args: dict[str, Any], tool_call_id: 
str | None) -> str | None:
+def fingerprint_tool_call(
+    name: str,
+    tool_args: dict[str, Any],
+    tool_call_id: str | None,
+    *,
+    step: int | None = None,
+) -> str | None:
     """
     Fingerprint a tool call: tool name, arguments, and the model-issued call 
id.
 
     ``tool_call_id`` round-trips through the model-response cache, so it is
     stable under faithful replay but regenerated whenever a live model call
     replaces a cached response -- chaining invalidation to downstream tool 
steps.
+
+    Only the name, arguments and call id are hashed, so neither model settings
+    nor the message history can affect a tool fingerprint. Arguments arrive as
+    live objects pydantic has already validated, and the tool has not run yet,
+    so they are rendered from copies (see ``_render``): hashing must neither 
order
+    a ``set`` unpredictably nor consume a lazily validated ``Iterable`` the 
tool
+    has not read, and must not change what the tool will see.
     """
     try:
-        return _digest({"name": name, "args": tool_args, "tool_call_id": 
tool_call_id})
-    except (TypeError, ValueError):
+        return _digest(
+            {"name": name, "args": _render(tool_args, copy_first=True), 
"tool_call_id": tool_call_id}
+        )
+    except Exception as exc:
+        # As for model requests: whatever rendering the arguments raises costs 
this
+        # step its verification, never the task.
         log.warning(
-            "Durable: could not fingerprint tool call; cached results for this 
"
-            "step replay without verification",
+            "Durable: could not fingerprint tool call; on retry this step 
replays without "
+            "verification, and only right after a step that replayed",
+            error=str(exc),
+            error_type=type(exc).__name__,
             tool=name,
+            step=step,
         )
         return None
diff --git 
a/providers/common/ai/tests/unit/common/ai/durable/test_capability.py 
b/providers/common/ai/tests/unit/common/ai/durable/test_capability.py
index c747afa147d..afd14d6c181 100644
--- a/providers/common/ai/tests/unit/common/ai/durable/test_capability.py
+++ b/providers/common/ai/tests/unit/common/ai/durable/test_capability.py
@@ -25,6 +25,7 @@ from __future__ import annotations
 
 import asyncio
 import dataclasses
+from collections.abc import Iterable
 from typing import TYPE_CHECKING, Any
 
 import pytest
@@ -243,6 +244,63 @@ class TestReplay:
         assert memory_storage.entries == {}
 
 
+class TestLazilyValidatedIterableArgument:
+    """
+    A lazily validated ``Iterable`` argument must reach the tool intact.
+
+    pydantic validates an ``Iterable[int]`` parameter into a 
``ValidatorIterator``, and
+    the call is fingerprinted before the tool runs. Reading it to build the 
fingerprint
+    would leave the tool an exhausted iterator, and the wrong answer would be 
recorded.
+    """
+
+    @staticmethod
+    def build(calls: Calls, seen: list[int], fail_final: list[bool]) -> 
Agent[None, str]:
+        def model_fn(messages: list[ModelMessage], info: AgentInfo) -> 
ModelResponse:
+            calls.bump("model")
+            if responses_so_far(messages) == 0:
+                return ModelResponse(parts=[ToolCallPart("total", {"values": 
[1, 2, 3]})])
+            if fail_final[0]:
+                raise RuntimeError("worker died")
+            return 
ModelResponse(parts=[TextPart(f"total={tool_returns(messages)[-1].content}")])
+
+        toolset = FunctionToolset(id="math")
+
+        @toolset.tool_plain
+        def total(values: Iterable[int]) -> int:
+            """Add up the given values."""
+            consumed = list(values)
+            seen.extend(consumed)
+            calls.bump("total")
+            return sum(consumed)
+
+        return Agent(
+            FunctionModel(model_fn), name="adder", toolsets=[toolset], 
capabilities=[AirflowDurability()]
+        )
+
+    @pytest.mark.asyncio
+    async def test_tool_still_receives_every_value(self, memory_storage):
+        seen: list[int] = []
+
+        result = await attempt(memory_storage, self.build(Calls(), seen, 
[False]))
+
+        assert seen == [1, 2, 3]
+        assert result.output == "total=6"
+
+    @pytest.mark.asyncio
+    async def test_retry_replays_the_result_of_the_full_input(self, 
memory_storage):
+        calls = Calls()
+        fail_final = [True]
+        with pytest.raises(RuntimeError):
+            await attempt(memory_storage, self.build(calls, [], fail_final))
+        fail_final[0] = False
+
+        result = await attempt(memory_storage, self.build(calls, [], 
fail_final))
+
+        assert result.output == "total=6"
+        # The call has no fingerprint, so it replays only because the model 
step before it did.
+        assert calls["total"] == 1
+
+
 class TestResultsThatCannotBeRecorded:
     @pytest.mark.asyncio
     async def 
test_a_function_tool_result_that_is_not_json_fails_without_retrying(self, 
memory_storage):
diff --git 
a/providers/common/ai/tests/unit/common/ai/durable/test_fingerprint.py 
b/providers/common/ai/tests/unit/common/ai/durable/test_fingerprint.py
index 5594fc3df7b..9cfa70f9e95 100644
--- a/providers/common/ai/tests/unit/common/ai/durable/test_fingerprint.py
+++ b/providers/common/ai/tests/unit/common/ai/durable/test_fingerprint.py
@@ -16,11 +16,34 @@
 # under the License.
 from __future__ import annotations
 
+import base64
+import dataclasses
 import datetime
+import enum
+import functools
+import hashlib
+import itertools
+import json
+import math
+import os
+import subprocess
+import sys
+from collections import deque
+from collections.abc import Iterable, Iterator, Mapping
+from decimal import Decimal
+from pathlib import PurePosixPath
+from typing import Annotated, Any
+from unittest.mock import patch
 
 import httpx
+import pydantic
+import pydantic.alias_generators
 import pytest
+from pydantic import TypeAdapter
+from pydantic_ai import Agent
 from pydantic_ai.messages import (
+    BinaryContent,
+    ModelMessagesTypeAdapter,
     ModelRequest,
     ModelResponse,
     SystemPromptPart,
@@ -30,14 +53,29 @@ from pydantic_ai.messages import (
     UserPromptPart,
 )
 from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.models.function import FunctionModel
+from pydantic_ai.settings import ToolOrOutput
 from pydantic_ai.tools import ToolDefinition
+from pydantic_core import to_jsonable_python
 
+from airflow.providers.common.ai.durable import fingerprint as 
fingerprint_module
 from airflow.providers.common.ai.durable.fingerprint import (
+    _LEAF,
+    _apply_template,
+    _digest,
+    _member_template,
     _normalize_params,
+    _render,
+    _seed_free,
+    _template,
+    _unpaired,
     fingerprint_model_request,
     fingerprint_tool_call,
 )
 
+# Not valid UTF-8, so a renderer that decodes bytes as text raises on it.
+_PNG = b"\x89PNG\r\n\x1a\n" + bytes(range(16))
+
 
 def make_messages(system: str = "You are a bot.", user: str = "hello", 
**part_kwargs):
     return [
@@ -316,7 +354,7 @@ class TestModelRequestFingerprint:
         assert fp is None
 
     def test_unserializable_settings_returns_none(self):
-        """Non-JSON settings values degrade to unverified replay instead of 
hashing
+        """Non-JSON settings values force live execution instead of hashing
         process-local reprs that would never match on retry."""
         fp = fingerprint_model_request(
             "m", make_messages(), {"extra_body": object()}, 
ModelRequestParameters()
@@ -326,7 +364,8 @@ class TestModelRequestFingerprint:
 
     def test_httpx_timeout_does_not_disable_fingerprint(self):
         """``timeout`` may be an ``httpx.Timeout`` (a supported, non-JSON 
shape).
-        It must not force the fingerprint to None and silently disable 
verification."""
+        It must not force the fingerprint to None, which would stop every 
model step
+        of the run from being cached or replayed."""
         fp = fingerprint_model_request(
             "m", make_messages(), {"timeout": httpx.Timeout(30.0)}, 
ModelRequestParameters()
         )
@@ -403,3 +442,1588 @@ class TestToolCallFingerprint:
         assert fingerprint_tool_call("t", {"a": 1, "b": 2}, "id1") == 
fingerprint_tool_call(
             "t", {"b": 2, "a": 1}, "id1"
         )
+
+
+class TestPydanticNativeValues:
+    """Values that are not JSON types but hash the same on every attempt must 
still fingerprint.
+
+    Tool arguments reach ``fingerprint_tool_call`` already coerced by 
pydantic, and
+    ``tool_choice`` accepts a dataclass while genuinely affecting the 
response, so it
+    cannot be stripped as transport-only. Since a step that cannot be 
fingerprinted is
+    no longer cached at all, refusing these values would stop an ordinary 
typed tool
+    from ever being cached.
+    """
+
+    def test_datetime_tool_argument_fingerprints(self):
+        when = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
+
+        assert fingerprint_tool_call("t", {"when": when}, "id1") is not None
+
+    def test_decimal_tool_argument_fingerprints(self):
+        assert fingerprint_tool_call("t", {"amount": Decimal("10.5")}, "id1") 
is not None
+
+    def test_datetime_tool_argument_is_stable_and_distinguishing(self):
+        early = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
+        late = datetime.datetime(2026, 6, 1, tzinfo=datetime.UTC)
+
+        assert fingerprint_tool_call("t", {"when": early}, "id1") == 
fingerprint_tool_call(
+            "t", {"when": early}, "id1"
+        )
+        assert fingerprint_tool_call("t", {"when": early}, "id1") != 
fingerprint_tool_call(
+            "t", {"when": late}, "id1"
+        )
+
+    def test_tool_choice_dataclass_fingerprints(self):
+        fp = fingerprint_model_request(
+            "m",
+            make_messages(),
+            {"tool_choice": ToolOrOutput(function_tools=["my_tool"])},
+            ModelRequestParameters(),
+        )
+
+        assert fp is not None
+
+    def test_tool_choice_dataclass_still_affects_the_fingerprint(self):
+        one = fingerprint_model_request(
+            "m",
+            make_messages(),
+            {"tool_choice": ToolOrOutput(function_tools=["a"])},
+            ModelRequestParameters(),
+        )
+        other = fingerprint_model_request(
+            "m",
+            make_messages(),
+            {"tool_choice": ToolOrOutput(function_tools=["b"])},
+            ModelRequestParameters(),
+        )
+
+        assert one is not None
+        assert one != other
+
+    def test_value_pydantic_cannot_serialize_still_returns_none(self):
+        """Normalization must not turn a genuinely unserializable value into a 
hash."""
+        assert fingerprint_tool_call("t", {"v": object()}, "id1") is None
+
+    def test_plain_payload_digest_is_unchanged_by_normalization(self):
+        """Fingerprints recorded before normalization must still match, so 
cached entries survive."""
+        payload = {"model": "m", "args": {"b": [1, True, None, "x", 2.5]}, 
"settings": None}
+        pre_normalization = hashlib.sha256(json.dumps(payload, 
sort_keys=True).encode()).hexdigest()
+
+        assert _digest(_render(payload)) == pre_normalization
+
+    def test_dict_keys_render_as_a_json_mode_dump_renders_them(self):
+        by_day = {datetime.date(2026, 1, 1): 1.5, datetime.date(2026, 1, 2): 
2.5}
+
+        assert _render({"series": by_day}) == {"series": {"2026-01-01": 1.5, 
"2026-01-02": 2.5}}
+        assert _render({1: "a", "b": 2}) == {"1": "a", "b": 2}
+
+    def test_keys_that_collide_once_rendered_are_refused(self):
+        """``1`` and ``"1"`` render alike, so hashing either payload could 
replay the other."""
+        assert fingerprint_tool_call("t", {"d": {1: "a", "1": "b"}}, "id1") is 
None
+
+    def test_keys_that_collide_only_in_the_message_dump_are_refused(self):
+        """The message dump renders ``inf`` and ``nan`` keys alike, where 
pydantic's defaults do not."""
+        fp = fingerprint_model_request(
+            "m", _with_tool_return({math.inf: 100, math.nan: "unpriced"}), 
None, ModelRequestParameters()
+        )
+
+        assert fp is None
+
+    def test_bytes_that_are_not_utf8_render_as_base64(self):
+        """Decoding bytes as text would raise on an image and stop the tool 
from being cached."""
+        assert _render({"image": _PNG}) == {"image": 
base64.urlsafe_b64encode(_PNG).decode()}
+        assert fingerprint_tool_call("t", {"image": _PNG}, "id1") is not None
+
+    def 
test_bytes_dict_keys_render_as_base64_with_the_sets_under_them_ordered(self):
+        key = base64.urlsafe_b64encode(_PNG).decode()
+
+        assert _render({"d": {_PNG: _MANY}}) == {"d": {key: sorted(_MANY)}}
+
+
+def _json_mode_reference(model_identifier, messages, model_request_parameters, 
settings=None):
+    """The fingerprint main computes: pydantic's json-mode dump, hashed as is, 
with plain JSON settings.
+
+    For anything that is not a set, fingerprints must equal this, or entries 
stored
+    by an earlier version stop matching and the first retry after an upgrade 
re-runs
+    the whole agent.
+    """
+    dumped = ModelMessagesTypeAdapter.dump_python(messages, mode="json")
+    stripped = [
+        {
+            **{k: v for k, v in message.items() if k not in ("timestamp", 
"run_id", "conversation_id")},
+            "parts": [{k: v for k, v in part.items() if k != "timestamp"} for 
part in message["parts"]],
+        }
+        for message in dumped
+    ]
+    params = 
TypeAdapter(ModelRequestParameters).dump_python(model_request_parameters, 
mode="json")
+    payload = {
+        "model": model_identifier,
+        "messages": stripped,
+        "settings": settings,
+        "params": _normalize_params(params),
+    }
+    return hashlib.sha256(json.dumps(payload, 
sort_keys=True).encode()).hexdigest()
+
+
+def _with_tool_return(content):
+    return [
+        ModelRequest(parts=[UserPromptPart(content="go")]),
+        ModelResponse(parts=[ToolCallPart(tool_name="t", args={}, 
tool_call_id="c1")]),
+        ModelRequest(parts=[ToolReturnPart(tool_name="t", content=content, 
tool_call_id="c1")]),
+    ]
+
+
+class TestMessageHistoryMatchesJsonModeDump:
+    """The message history must hash exactly as pydantic's json-mode dump 
renders it.
+
+    Each case here is something a hand-written renderer gets wrong: raw bytes 
(not
+    valid UTF-8), dict keys that are not strings, ``NaN``, tuples, and fields 
whose
+    serializer only applies in JSON mode, such as the set-typed request 
parameters
+    and, on pydantic-ai versions that have it, ``InstructionPart.id``.
+    """
+
+    @pytest.mark.parametrize(
+        "messages",
+        [
+            pytest.param(
+                [
+                    ModelRequest(
+                        parts=[
+                            UserPromptPart(content=["look", 
BinaryContent(data=_PNG, media_type="image/png")])
+                        ]
+                    )
+                ],
+                id="binary-content-in-prompt",
+            ),
+            pytest.param(_with_tool_return(_PNG), id="bytes-tool-return"),
+            pytest.param(
+                _with_tool_return({datetime.date(2026, 1, 1): 1.5, 
datetime.date(2026, 1, 2): 2.5}),
+                id="date-keyed-tool-return",
+            ),
+            pytest.param(_with_tool_return({1: "a", "b": 2}), 
id="mixed-key-tool-return"),
+            pytest.param(_with_tool_return({"x": math.nan}), 
id="nan-tool-return"),
+            pytest.param(
+                _with_tool_return({"when": datetime.datetime(2026, 1, 1)}), 
id="datetime-tool-return"
+            ),
+            
pytest.param([ModelRequest(parts=(UserPromptPart(content="hi"),))], 
id="tuple-parts"),
+            pytest.param(_with_tool_return({"pair": ("a", 1)}), 
id="tuple-tool-return"),
+        ],
+    )
+    def test_fingerprint_equals_the_json_mode_digest(self, messages):
+        fp = fingerprint_model_request("m", messages, None, 
ModelRequestParameters())
+
+        assert fp is not None
+        assert fp == _json_mode_reference("m", messages, 
ModelRequestParameters())
+
+    def test_request_parameters_hash_as_their_json_mode_dump(self):
+        """The request parameters dump differently in python and json mode: 
set-typed fields
+        such as ``revealed_tool_names`` on every supported version, and 
``InstructionPart.id``
+        on the versions that have it."""
+        seen = {}
+
+        class Spy(FunctionModel):
+            async def request(self, messages, model_settings, 
model_request_parameters):
+                seen.setdefault("messages", messages)
+                seen.setdefault("params", model_request_parameters)
+                return await super().request(messages, model_settings, 
model_request_parameters)
+
+        async def respond(messages, info):
+            return ModelResponse(parts=[TextPart(content="ok")])
+
+        Agent(Spy(respond), instructions="Be terse.").run_sync("hi")
+
+        fp = fingerprint_model_request("m", seen["messages"], None, 
seen["params"])
+
+        assert fp == _json_mode_reference("m", seen["messages"], 
seen["params"])
+
+    def test_tuple_parts_still_drop_part_timestamps(self):
+        """Message history passed as objects can hold its parts in a tuple."""
+        t1 = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
+        t2 = datetime.datetime(2026, 1, 2, tzinfo=datetime.UTC)
+
+        def history(timestamp):
+            return [ModelRequest(parts=(UserPromptPart(content="hi", 
timestamp=timestamp),))]
+
+        assert fingerprint_model_request(
+            "m", history(t1), None, ModelRequestParameters()
+        ) == fingerprint_model_request("m", history(t2), None, 
ModelRequestParameters())
+
+    def test_set_in_a_tool_return_hashes_as_its_ordered_members(self):
+        assert fingerprint_model_request(
+            "m", _with_tool_return({"tags": {"b", "c", "a"}}), None, 
ModelRequestParameters()
+        ) == fingerprint_model_request(
+            "m", _with_tool_return({"tags": ["a", "b", "c"]}), None, 
ModelRequestParameters()
+        )
+
+
+class TestSetMemberOrdering:
+    """Sets must hash in a fixed order rather than the interpreter's iteration 
order.
+
+    A ``set[str]`` iterates in an order derived from the process hash seed, 
and every
+    task attempt runs in a fresh process. Hashing that order would produce a 
digest
+    the next attempt never reproduces, so the step would re-run live on every 
retry
+    -- worse than declining to cache it, which at least costs nothing extra.
+    """
+
+    def test_set_hashes_as_its_ordered_members(self):
+        assert _render({"tags": {"beta", "alpha"}}) == {"tags": ["alpha", 
"beta"]}
+
+    def test_set_matches_the_equivalent_list(self):
+        assert _render({"tags": {"alpha", "beta", "gamma"}}) == 
_render({"tags": ["alpha", "beta", "gamma"]})
+
+    def test_frozenset_matches_set(self):
+        assert _render({"tags": frozenset({"a", "b"})}) == _render({"tags": 
{"b", "a"}})
+
+    def test_different_members_still_produce_different_digests(self):
+        assert _render({"tags": {"a", "b"}}) != _render({"tags": {"a", "c"}})
+
+    def test_set_nested_inside_a_list(self):
+        assert _render({"filters": [{"z", "y"}]}) == {"filters": [["y", "z"]]}
+
+    def test_set_inside_a_dataclass_field(self):
+        @dataclasses.dataclass
+        class Filter:
+            tags: set
+
+        assert _render(Filter(tags={"b", "a"})) == {"tags": ["a", "b"]}
+
+    def test_set_inside_a_basemodel_field(self):
+        class Filter(pydantic.BaseModel):
+            tags: set[str]
+
+        assert _render(Filter(tags={"b", "a"})) == {"tags": ["a", "b"]}
+
+
+class TestLazilyValidatedIterable:
+    """A lazily validated ``Iterable`` must be refused, not consumed.
+
+    ``to_jsonable_python`` drains an iterator to render it. Tool arguments are
+    fingerprinted before the tool runs, so draining one would hand the tool an
+    exhausted iterator and cache that wrong result under the fingerprint of the
+    full input. Declining to fingerprint leaves the step uncached instead.
+    """
+
+    def test_validator_iterator_argument_is_not_fingerprinted(self):
+        values = pydantic.TypeAdapter(Iterable[int]).validate_python([1, 2, 3])
+
+        assert fingerprint_tool_call("total", {"values": values}, "id1") is 
None
+
+    def test_the_iterator_is_left_unread(self):
+        values = pydantic.TypeAdapter(Iterable[int]).validate_python([1, 2, 3])
+
+        fingerprint_tool_call("total", {"values": values}, "id1")
+
+        assert list(values) == [1, 2, 3]
+
+    def test_a_generator_is_refused_too(self):
+        assert fingerprint_tool_call("total", {"values": (n for n in (1, 2))}, 
"id1") is None
+
+    def test_an_ordinary_list_argument_still_fingerprints(self):
+        assert fingerprint_tool_call("total", {"values": [1, 2, 3]}, "id1") is 
not None
+
+
+class _RowsWithFirst(pydantic.BaseModel):
+    rows: list[int]
+
+    @pydantic.computed_field  # type: ignore[prop-decorator]
+    @property
+    def first(self) -> int:
+        return self.rows[0]
+
+
[email protected]
+class _SizedName:
+    name: str
+    size: int = dataclasses.field(init=False)
+
+
+class _UnreadableMapping(Mapping):
+    def __getitem__(self, key):
+        raise KeyError(key)
+
+    def __iter__(self):
+        return iter(["a"])
+
+    def __len__(self):
+        return 1
+
+
+class _TicketWithHeadline(pydantic.BaseModel):
+    model_config = pydantic.ConfigDict(extra="allow")
+    tags: list[str]
+
+    @property
+    def headline(self) -> str:
+        return self.tags[0]
+
+
+class TestUnwalkablePayloadsDegradeRatherThanRaise:
+    """Whatever the renderer cannot walk must become ``None``, never propagate.
+
+    A failed fingerprint costs a re-run; an exception escaping here would fail 
the
+    task outright, which durable execution must never do on its own account.
+    """
+
+    def test_circular_reference_returns_none(self):
+        """The iterator and set walks recurse before pydantic can apply its 
own cycle check."""
+        cycle: dict = {}
+        cycle["self"] = cycle
+
+        assert fingerprint_tool_call("t", {"v": cycle}, "id1") is None
+
+    def test_circular_reference_in_model_settings_returns_none(self):
+        cycle: dict = {}
+        cycle["self"] = cycle
+
+        fp = fingerprint_model_request(
+            "m",
+            make_messages(),
+            {"extra_body": cycle},
+            ModelRequestParameters(),
+        )
+
+        assert fp is None
+
+    @pytest.mark.parametrize(
+        "value",
+        [
+            pytest.param(_RowsWithFirst(rows=[]), id="computed-field-raises"),
+            pytest.param(_SizedName(name="a"), id="unset-init-false-field"),
+            pytest.param(_UnreadableMapping(), id="mapping-raises"),
+        ],
+    )
+    def test_error_raised_by_user_code_returns_none(self, value):
+        """Rendering runs computed fields, reads dataclass fields and iterates 
mappings."""
+        assert fingerprint_tool_call("t", {"v": value}, "id1") is None
+
+    def test_error_raised_by_user_code_in_a_tool_return_returns_none(self):
+        fp = fingerprint_model_request(
+            "m", _with_tool_return(_RowsWithFirst(rows=[])), None, 
ModelRequestParameters()
+        )
+
+        assert fp is None
+
+    def test_warning_names_the_type_of_the_error(self):
+        # Patched rather than captured: on Airflow 2 the task logger wraps the 
stdlib logger,
+        # which structlog's test capture does not see.
+        with patch("airflow.providers.common.ai.durable.fingerprint.log") as 
fingerprint_log:
+            fingerprint_tool_call("t", {"v": _RowsWithFirst(rows=[])}, "id1")
+
+        fingerprint_log.warning.assert_called_once()
+        assert fingerprint_log.warning.call_args.kwargs["error_type"] == 
"IndexError"
+
+    def test_extra_named_like_a_property_does_not_run_the_property(self):
+        """The property is no part of the rendering, so running it could only 
raise."""
+        ticket = _TicketWithHeadline.model_validate({"tags": [], "headline": 
"n/a"})
+
+        assert fingerprint_tool_call("t", {"ticket": ticket}, "id1") is not 
None
+
+
+def _lazy_ints():
+    return pydantic.TypeAdapter(Iterable[int]).validate_python([1, 2, 3])
+
+
+class _QueryWithIterableField(pydantic.BaseModel):
+    ids: Iterable[int]
+
+
+class _QueryWithIterableExtra(pydantic.BaseModel):
+    model_config = pydantic.ConfigDict(extra="allow")
+    __pydantic_extra__: dict[str, Iterable[int]]
+
+
[email protected]
+class _GroupWithIterable:
+    ids: Iterable[int]
+
+
+class _QueryWithPrivateIterator(pydantic.BaseModel):
+    _ids: Iterator[int] = pydantic.PrivateAttr()
+
+    @pydantic.computed_field  # type: ignore[prop-decorator]
+    @property
+    def ids(self) -> list[int]:
+        return list(self._ids)
+
+
+class _BatchWithPendingIds(pydantic.BaseModel):
+    ids: list[int]
+
+    @pydantic.computed_field  # type: ignore[prop-decorator]
+    @functools.cached_property
+    def pending(self) -> Iterable[int]:
+        return iter(self.ids)
+
+
+class TestNestedIterators:
+    """An iterator nested anywhere in the arguments is refused, and left 
unread for the tool."""
+
+    @pytest.mark.parametrize(
+        "shape", ["list", "tuple", "dict", "model-field", "dataclass-field", 
"model-extra"]
+    )
+    def test_nested_iterator_is_refused_and_left_unread(self, shape):
+        if shape == "model-field":
+            holder = _QueryWithIterableField(ids=[1, 2, 3])
+            args, values = {"query": holder}, holder.ids
+        elif shape == "model-extra":
+            holder = _QueryWithIterableExtra(ids=[1, 2, 3])
+            args, values = {"query": holder}, holder.model_extra["ids"]
+        else:
+            values = _lazy_ints()
+            args = {
+                "list": {"groups": [values]},
+                "tuple": {"groups": (values,)},
+                "dict": {"groups": {"a": values}},
+                "dataclass-field": {"group": _GroupWithIterable(ids=values)},
+            }[shape]
+
+        assert fingerprint_tool_call("total", args, "id1") is None
+        assert list(values) == [1, 2, 3]
+
+    def test_generator_in_a_tool_return_is_refused_and_left_unread(self):
+        """The model still receives every value, where rendering it first 
would hand it []."""
+        returned = (n for n in (1, 2, 3))
+
+        fp = fingerprint_model_request("m", _with_tool_return(returned), None, 
ModelRequestParameters())
+
+        assert fp is None
+        assert list(returned) == [1, 2, 3]
+
+    def test_iterator_a_computed_field_reads_is_read_from_a_copy(self):
+        """Tool arguments render from copies, so the tool still gets every 
value."""
+
+        def query():
+            built = _QueryWithPrivateIterator()
+            built._ids = iter([1, 2, 3])
+            return built
+
+        original = query()
+
+        fp = fingerprint_tool_call("total", {"query": original}, "id1")
+
+        assert fp is not None
+        assert fp == fingerprint_tool_call("total", {"query": query()}, "id1")
+        assert list(original._ids) == [1, 2, 3]
+
+    def test_iterator_a_computed_field_caches_is_refused_and_left_unread(self):
+        """Rendering would evaluate the cached property and drain what it 
caches."""
+        batch = _BatchWithPendingIds(ids=[1, 2, 3])
+
+        assert fingerprint_tool_call("total", {"batch": batch}, "id1") is None
+        assert list(batch.pending) == [1, 2, 3]
+
+    def 
test_iterator_a_computed_field_caches_in_a_tool_return_is_left_unread(self):
+        batch = _BatchWithPendingIds(ids=[1, 2, 3])
+
+        fp = fingerprint_model_request("m", _with_tool_return(batch), None, 
ModelRequestParameters())
+
+        assert fp is None
+        assert list(batch.pending) == [1, 2, 3]
+
+
+class _HeadersWithoutAuth(pydantic.BaseModel):
+    headers: dict[str, str]
+
+    @pydantic.field_serializer("headers")
+    def _drop_authorization(self, headers: dict[str, str]) -> dict[str, str]:
+        return {key: value for key, value in headers.items() if key != 
"authorization"}
+
+
+class _QueryWithSortedFilters(pydantic.BaseModel):
+    filters: dict[str, Any]
+
+    @pydantic.field_serializer("filters")
+    def _sort(self, filters: dict[str, Any]) -> dict[str, Any]:
+        return dict(sorted(filters.items()))
+
+
+def _drop_authorization(headers: dict[str, str]) -> dict[str, str]:
+    return {key: value for key, value in headers.items() if key != 
"authorization"}
+
+
+def _sort_filters(filters: dict[str, Any]) -> dict[str, Any]:
+    return dict(sorted(filters.items()))
+
+
+def _split_groups(groups: Iterable[str]) -> list[str]:
+    return [part for group in groups for part in group.split(",")]
+
+
+def _split_groups_sorted(groups: Iterable[str]) -> list[str]:
+    return sorted(_split_groups(groups))
+
+
+# A serializer nested inside the annotation is not one of the field's own, so 
the
+# rendering under it is still walked, and the checks below are what keep it 
safe.
+class _RequestWithNestedRedaction(pydantic.BaseModel):
+    headers: Annotated[dict[str, str], 
pydantic.PlainSerializer(_drop_authorization)] | None
+    tags: set[str]
+
+
+class _QueryWithNestedSortedFilters(pydantic.BaseModel):
+    filters: Annotated[dict[str, Any], 
pydantic.PlainSerializer(_sort_filters)] | None
+
+
+class _CsvGroupList(pydantic.BaseModel):
+    groups: Annotated[list[str], pydantic.PlainSerializer(_split_groups)] | 
None
+    tags: set[str]
+
+
+class _CsvGroupSet(pydantic.BaseModel):
+    groups: Annotated[set[str], 
pydantic.PlainSerializer(_split_groups_sorted)] | None
+    tags: set[str]
+
+
+class _SizesByPath(pydantic.BaseModel):
+    sizes: dict[PurePosixPath, int]
+    tags: set[str]
+
+
+class TestSerializersThatChangeKeys:
+    """A serializer that drops or reorders keys is left as pydantic rendered 
it."""
+
+    def test_dropped_key_is_not_mistaken_for_a_collision(self):
+        messages = _with_tool_return(
+            _HeadersWithoutAuth(headers={"authorization": "secret", "accept": 
"json"})
+        )
+
+        fp = fingerprint_model_request("m", messages, None, 
ModelRequestParameters())
+
+        assert fp is not None
+        assert fp == _json_mode_reference("m", messages, 
ModelRequestParameters())
+
+    def 
test_key_dropped_by_a_nested_serializer_is_not_mistaken_for_a_collision(self):
+        """The set makes the rendering worth checking, so the collision check 
does run here."""
+        request = _RequestWithNestedRedaction(
+            headers={"authorization": "secret", "accept": "json"}, 
tags=set(_MANY)
+        )
+
+        assert _render({"r": request}) == {"r": {"headers": {"accept": 
"json"}, "tags": sorted(_MANY)}}
+        assert (
+            fingerprint_model_request("m", _with_tool_return(request), None, 
ModelRequestParameters())
+            is not None
+        )
+
+    @pytest.mark.parametrize(
+        "model", [_QueryWithSortedFilters, _QueryWithNestedSortedFilters], 
ids=["field", "nested"]
+    )
+    def test_reordered_keys_do_not_pair_a_value_with_the_wrong_rendering(self, 
model):
+        """Pairing by position would sort ``order`` as if it were the set, so 
both orders hash alike."""
+        one = model(filters={"status": {3, 7}, "order": ["created", "id"]})
+        other = model(filters={"status": {3, 7}, "order": ["id", "created"]})
+
+        assert fingerprint_tool_call("t", {"q": one}, "id1") != 
fingerprint_tool_call(
+            "t", {"q": other}, "id1"
+        )
+
+    @pytest.mark.parametrize("model", [_CsvGroupList, _CsvGroupSet], 
ids=["list", "set"])
+    def test_serializer_that_changes_a_length_is_not_truncated(self, model):
+        """Pairing by position would stop at the shorter side, so ``a,b`` and 
``a,c`` would hash alike."""
+        one = model(groups=["a,b"], tags={"x"})
+        other = model(groups=["a,c"], tags={"x"})
+
+        assert fingerprint_tool_call("t", {"q": one}, "id1") != 
fingerprint_tool_call(
+            "t", {"q": other}, "id1"
+        )
+
+    def test_colliding_keys_are_still_refused(self):
+        assert fingerprint_tool_call("t", {"d": {1: "a", "1": "b"}}, "id1") is 
None
+
+    def test_keys_only_the_enclosing_schema_can_render_still_fingerprint(self):
+        """pydantic renders a ``PurePosixPath`` key through the model's 
schema, but not on its own."""
+        value = _SizesByPath(sizes={PurePosixPath("a/b"): 1}, tags={"x"})
+
+        assert fingerprint_tool_call("t", {"v": value}, "id1") is not None
+        assert (
+            fingerprint_model_request("m", _with_tool_return(value), None, 
ModelRequestParameters())
+            is not None
+        )
+
+
+_MANY = {f"tag-{n}" for n in range(12)}
+
+
+class TestAliasedAndRootModelSets:
+    """Sets under an alias or in a ``RootModel`` are ordered too."""
+
+    def test_set_under_an_alias_is_ordered(self):
+        class Aliased(pydantic.BaseModel):
+            tags: set[str] = pydantic.Field(alias="labels")
+
+        assert _render(Aliased(labels=_MANY)) == {"labels": sorted(_MANY)}
+
+    def test_set_under_a_generated_alias_is_ordered(self):
+        class Camel(pydantic.BaseModel):
+            model_config = pydantic.ConfigDict(
+                alias_generator=pydantic.alias_generators.to_camel, 
serialize_by_alias=True
+            )
+            my_tags: set[str]
+
+        assert _render(Camel(myTags=_MANY)) == {"myTags": sorted(_MANY)}
+
+    def test_root_model_set_is_ordered(self):
+        assert _render(pydantic.RootModel[set[str]](_MANY)) == sorted(_MANY)
+
+    def test_set_under_a_serialization_alias_is_ordered(self):
+        class SerializationAlias(pydantic.BaseModel):
+            tags: set[str] = pydantic.Field(serialization_alias="labels")
+
+        assert _render(SerializationAlias(tags=_MANY)) == {"labels": 
sorted(_MANY)}
+
+    def test_set_under_an_alias_in_a_pydantic_dataclass_is_ordered(self):
+        @pydantic.dataclasses.dataclass
+        class Aliased:
+            tags: set[str] = pydantic.Field(alias="labels")
+
+        assert _render(Aliased(labels=_MANY)) == {"labels": sorted(_MANY)}  # 
type: ignore[call-arg]
+
+
+def _earlier_tool_fingerprint(name, tool_args, tool_call_id):
+    """What an earlier version stored for a tool call: the raw arguments, 
hashed by ``json``."""
+    payload = {"name": name, "args": tool_args, "tool_call_id": tool_call_id}
+    return hashlib.sha256(json.dumps(payload, 
sort_keys=True).encode()).hexdigest()
+
+
+class TestPlainJsonHashesAsBefore:
+    """Plain JSON arguments and settings keep the digests an earlier version 
stored.
+
+    ``json`` orders numeric keys by value and writes ``None`` and ``inf`` keys 
as
+    ``null`` and ``Infinity``, and it nests far deeper than pydantic renders. 
Sending
+    plain JSON through pydantic would change or lose those digests, so an 
entry cached
+    before an upgrade would stop matching and a tool that is not idempotent 
would run
+    twice.
+    """
+
+    @pytest.mark.parametrize(
+        "tool_args",
+        [
+            pytest.param({"bias": {9: 0.5, 10: 0.25}}, id="int-keys"),
+            pytest.param({"bands": {2.5: "a", 10.5: "b"}}, id="float-keys"),
+            pytest.param({"d": {None: 1}}, id="none-key"),
+            pytest.param({"d": {math.inf: 1}}, id="inf-key"),
+        ],
+    )
+    def test_tool_arguments_hash_as_before(self, tool_args):
+        assert fingerprint_tool_call("t", tool_args, "id1") == 
_earlier_tool_fingerprint(
+            "t", tool_args, "id1"
+        )
+
+    def test_settings_hash_as_before(self):
+        settings: Any = {"extra_body": {"logit_bias": {9: 1, 10: -1}}}
+        messages = make_messages()
+
+        fp = fingerprint_model_request("m", messages, settings, 
ModelRequestParameters())
+
+        assert fp == _json_mode_reference("m", messages, 
ModelRequestParameters(), settings=settings)
+
+    def test_deeply_nested_arguments_hash_as_before(self):
+        tree: dict[str, Any] = {"leaf": 1}
+        for _ in range(200):
+            tree = {"child": tree}
+
+        assert fingerprint_tool_call("t", {"tree": tree}, "id1") == 
_earlier_tool_fingerprint(
+            "t", {"tree": tree}, "id1"
+        )
+
+
+class _ColumnsWithExtras(pydantic.BaseModel):
+    model_config = pydantic.ConfigDict(extra="allow")
+    columns: set[str] = pydantic.Field(alias="Columns")
+
+
+class _SwappedAliases(pydantic.BaseModel):
+    first: list[str] = pydantic.Field(alias="second")
+    second: set[str] = pydantic.Field(alias="first")
+
+
+class _RankedTags(pydantic.BaseModel):
+    tags: set[str] = pydantic.Field(alias="labels")
+    ranking: list[str] = pydantic.Field(exclude=True)
+
+    @pydantic.computed_field(alias="tags")  # type: ignore[prop-decorator]
+    @property
+    def ranked(self) -> list[str]:
+        return self.ranking
+
+
+class _RowsWithTotal(pydantic.BaseModel):
+    model_config = pydantic.ConfigDict(extra="allow")
+    rows: list[int]
+
+    @pydantic.computed_field  # type: ignore[prop-decorator]
+    @property
+    def total(self) -> int:
+        return sum(self.rows)
+
+
+class TestRenderedKeysPairWithTheirOwnValues:
+    """A set is sorted where it rendered, never a list that rendered under a 
key it shares a name with.
+
+    Sorting such a list would make two payloads that differ only in its order 
hash
+    alike, and replay the cached result of one for the other.
+    """
+
+    def test_extra_named_like_an_aliased_field(self):
+        def query(order):
+            return _ColumnsWithExtras.model_validate({"Columns": ["x", "y"], 
"columns": order})
+
+        assert fingerprint_tool_call("t", {"q": query(["a", "b"])}, "id1") != 
fingerprint_tool_call(
+            "t", {"q": query(["b", "a"])}, "id1"
+        )
+
+    def test_swapped_aliases_in_a_tool_return(self):
+        """The message history renders fields by name, so an alias must not 
pair them."""
+
+        def fingerprint(order):
+            returned = _SwappedAliases.model_validate({"second": order, 
"first": {"x", "y"}})
+            return fingerprint_model_request("m", _with_tool_return(returned), 
None, ModelRequestParameters())
+
+        assert fingerprint(["a", "b"]) is not None
+        assert fingerprint(["a", "b"]) != fingerprint(["b", "a"])
+
+    def test_computed_field_aliased_like_a_field_name(self):
+        def query(ranking):
+            return _RankedTags(labels={"x", "y"}, ranking=ranking)
+
+        assert fingerprint_tool_call("t", {"q": query(["x", "y"])}, "id1") != 
fingerprint_tool_call(
+            "t", {"q": query(["y", "x"])}, "id1"
+        )
+
+    def test_extra_that_overwrites_a_field_is_hashed_as_rendered(self):
+        """By name the extra's list renders under the field's key, and is not 
sorted as the field's set."""
+        query = _ColumnsWithExtras.model_validate({"Columns": ["x", "y"], 
"columns": ["b", "a"]})
+        messages = _with_tool_return(query)
+
+        assert fingerprint_model_request("m", messages, None, 
ModelRequestParameters()) == (
+            _json_mode_reference("m", messages, ModelRequestParameters())
+        )
+        # By alias the two keys differ, and only the field's set is sorted.
+        assert _render({"q": query}) == {"q": {"Columns": ["x", "y"], 
"columns": ["b", "a"]}}
+
+    def 
test_extra_that_a_computed_field_overwrites_is_hashed_as_rendered(self):
+        messages = _with_tool_return(_RowsWithTotal.model_validate({"rows": 
[1], "total": 99}))
+
+        assert fingerprint_model_request("m", messages, None, 
ModelRequestParameters()) == (
+            _json_mode_reference("m", messages, ModelRequestParameters())
+        )
+
+
+# U+00E9, an accented "e", sorts after "z" as text, but its JSON escape 
"\\u00e9" sorts
+# before it, so re-sorting a serializer's output by its JSON encoding would 
reorder it.
+_ACCENTED = frozenset({"z", "é"})
+
+
+class _SortedByFieldSerializer(pydantic.BaseModel):
+    tags: frozenset[str]
+
+    @pydantic.field_serializer("tags")
+    def _sorted(self, tags: frozenset[str]) -> list[str]:
+        return sorted(tags)
+
+
+class _SortedByWildcardSerializer(pydantic.BaseModel):
+    tags: frozenset[str]
+
+    @pydantic.field_serializer("*")
+    def _sorted(self, value: Any) -> Any:
+        return sorted(value)
+
+
+class _SortedByAnnotation(pydantic.BaseModel):
+    tags: Annotated[frozenset[str], pydantic.PlainSerializer(sorted)]
+
+
+class _SortedByModelSerializer(pydantic.BaseModel):
+    tags: frozenset[str]
+
+    @pydantic.model_serializer
+    def _sorted(self) -> dict[str, Any]:
+        return {"tags": sorted(self.tags)}
+
+
+class _SortedRoot(pydantic.RootModel[frozenset[str]]):
+    @pydantic.field_serializer("root")
+    def _sorted(self, tags: frozenset[str]) -> list[str]:
+        return sorted(tags)
+
+
+class _SortedNested(pydantic.BaseModel):
+    tags: Annotated[frozenset[str], pydantic.PlainSerializer(sorted)] | None
+
+
+class _SortedInList(pydantic.BaseModel):
+    groups: list[Annotated[frozenset[str], pydantic.PlainSerializer(sorted)]]
+
+
+class TestCustomSerializersKeepTheirOrder:
+    """A list a serializer produced is hashed as the serializer produced it."""
+
+    @pytest.mark.parametrize(
+        "content",
+        [
+            pytest.param(_SortedByFieldSerializer(tags=_ACCENTED), 
id="field-serializer"),
+            pytest.param(_SortedByWildcardSerializer(tags=_ACCENTED), 
id="wildcard-field-serializer"),
+            pytest.param(_SortedByAnnotation(tags=_ACCENTED), 
id="annotated-serializer"),
+            pytest.param(_SortedByModelSerializer(tags=_ACCENTED), 
id="model-serializer"),
+            pytest.param(_SortedRoot(_ACCENTED), id="root-model-serializer"),
+            pytest.param(_SortedNested(tags=_ACCENTED), 
id="serializer-nested-in-optional"),
+            pytest.param(_SortedInList(groups=[_ACCENTED]), 
id="serializer-nested-in-list"),
+        ],
+    )
+    def test_serializer_output_is_not_resorted(self, content):
+        messages = _with_tool_return(content)
+
+        assert fingerprint_model_request("m", messages, None, 
ModelRequestParameters()) == (
+            _json_mode_reference("m", messages, ModelRequestParameters())
+        )
+        assert _render({"c": content}) == to_jsonable_python({"c": content})
+
+
+class _FilterWithExtras(pydantic.BaseModel):
+    model_config = pydantic.ConfigDict(extra="allow")
+    name: str
+
+
+class _FilterWithCount(pydantic.BaseModel):
+    tags: set[str]
+
+    @pydantic.computed_field  # type: ignore[prop-decorator]
+    @property
+    def count(self) -> int:
+        return len(self.tags)
+
+
+class _FilterWithVersion(pydantic.BaseModel):
+    tags: set[str]
+
+    @pydantic.model_serializer(mode="wrap")
+    def _with_version(self, handler: Any) -> dict[str, Any]:
+        return {**handler(self), "version": 2}
+
+
+class _FilterWithTagView(pydantic.BaseModel):
+    tags: list[str]
+
+    @pydantic.computed_field(alias="Tags")  # type: ignore[prop-decorator]
+    @property
+    def view(self) -> set[str]:
+        return set(self.tags)
+
+
+class TestRenderedKeysWithoutAField:
+    """Keys that belong to no declared field -- extras, computed fields, keys 
a serializer adds -- still fingerprint."""
+
+    @pytest.mark.parametrize(
+        ("build", "expected"),
+        [
+            pytest.param(
+                lambda: _FilterWithExtras.model_validate({"name": "a", "tags": 
set(_MANY)}),
+                {"name": "a", "tags": sorted(_MANY)},
+                id="extra",
+            ),
+            pytest.param(
+                lambda: _FilterWithCount(tags=set(_MANY)),
+                {"tags": sorted(_MANY), "count": 12},
+                id="computed-field",
+            ),
+            pytest.param(
+                lambda: _FilterWithTagView(tags=sorted(_MANY, reverse=True)),
+                {"tags": sorted(_MANY, reverse=True), "Tags": sorted(_MANY)},
+                id="computed-set",
+            ),
+            pytest.param(
+                lambda: _FilterWithVersion(tags=set(_MANY)),
+                {"tags": sorted(_MANY), "version": 2},
+                id="serializer-added-key",
+            ),
+        ],
+    )
+    def test_sets_are_ordered_and_the_other_keys_kept(self, build, expected):
+        assert _render({"f": build()}) == {"f": expected}
+        assert (
+            fingerprint_model_request("m", _with_tool_return(build()), None, 
ModelRequestParameters())
+            is not None
+        )
+
+
+class _Scope(enum.Enum):
+    READ = frozenset(_MANY)
+
+
+class TestSetsElsewhereAreOrdered:
+    """Sets reached through an ``Enum``, model settings or another set are 
ordered too."""
+
+    def test_set_that_is_an_enum_value_is_ordered(self):
+        assert _render({"scope": _Scope.READ}) == {"scope": sorted(_MANY)}
+        assert fingerprint_model_request(
+            "m", _with_tool_return(_Scope.READ), None, ModelRequestParameters()
+        ) == fingerprint_model_request("m", _with_tool_return(sorted(_MANY)), 
None, ModelRequestParameters())
+
+    def test_set_in_model_settings_is_ordered(self):
+        def fingerprint(include):
+            settings: Any = {"extra_body": {"include": include}}
+            return fingerprint_model_request("m", make_messages(), settings, 
ModelRequestParameters())
+
+        assert fingerprint(set(_MANY)) == fingerprint(sorted(_MANY))
+
+    def test_sets_inside_a_set_are_ordered(self):
+        groups = {frozenset(_MANY), frozenset({"epsilon", "delta"})}
+
+        assert _render({"groups": groups}) == {"groups": [["delta", 
"epsilon"], sorted(_MANY)]}
+
+
+class _Windows(pydantic.BaseModel):
+    windows: deque[Iterable[int]]
+
+
+class _RecentTags(pydantic.BaseModel):
+    recent: deque[frozenset[str]]
+
+
[email protected]_config(pydantic.ConfigDict(alias_generator=pydantic.alias_generators.to_camel))
[email protected]
+class _CamelTags:
+    user_tags: set[str]
+
+
+class _HoldsCamelTags(pydantic.BaseModel):
+    tags: _CamelTags
+
+
+class _RankedOverExcluded(pydantic.BaseModel):
+    tags: set[str] = pydantic.Field(exclude=True)
+    ranking: list[str] = pydantic.Field(exclude=True)
+
+    @pydantic.computed_field(alias="tags")  # type: ignore[prop-decorator]
+    @property
+    def ranked(self) -> list[str]:
+        return self.ranking
+
+
+class _Folder(pydantic.BaseModel):
+    name: str
+    children: list[_Folder] = []
+    _parent: _Folder | None = pydantic.PrivateAttr(default=None)
+
+
+class _Cart(pydantic.BaseModel):
+    prices: list[float]
+
+    @pydantic.computed_field  # type: ignore[prop-decorator]
+    @functools.cached_property
+    def total(self) -> float:
+        return sum(self.prices)
+
+
+class _Upload(pydantic.BaseModel):
+    rows: Iterable[int]
+
+    @pydantic.computed_field  # type: ignore[prop-decorator]
+    @functools.cached_property
+    def preview(self) -> list[int]:
+        return list(itertools.islice(self.rows, 2))
+
+
[email protected]
+class _PendingBatch:
+    ids: list[int]
+
+    @pydantic.computed_field  # type: ignore[prop-decorator]
+    @functools.cached_property
+    def pending(self) -> Iterable[int]:
+        return iter(self.ids)
+
+
+def _camel_without_none(options: dict[str, Any]) -> dict[str, Any]:
+    return {
+        pydantic.alias_generators.to_camel(key): value for key, value in 
options.items() if value is not None
+    }
+
+
+class _Job(pydantic.BaseModel):
+    options: Annotated[dict[str, Any], 
pydantic.PlainSerializer(_camel_without_none)] | None
+
+
+class _JsonOnlyLonger(pydantic.BaseModel):
+    tags: set[str]
+    rows: list[str]
+
+    @pydantic.model_serializer(mode="wrap", when_used="json")
+    def _append(self, handler: Any) -> dict[str, Any]:
+        data = handler(self)
+        return {"tags": [*data["tags"], "extra"], "rows": [*data["rows"], 
"extra"]}
+
+
+class _JsonOnlyRenamed(pydantic.BaseModel):
+    tags: set[str]
+
+    @pydantic.model_serializer(mode="wrap", when_used="json")
+    def _rename(self, handler: Any) -> dict[str, Any]:
+        return {"labels": handler(self)["tags"]}
+
+
+class _JsonOnlyRedacted(pydantic.BaseModel):
+    headers: dict[str, str]
+    tags: set[str]
+
+    @pydantic.field_serializer("headers", when_used="json")
+    def _redact(self, headers: dict[str, str]) -> dict[str, str]:
+        return {key: value for key, value in headers.items() if key != 
"authorization"}
+
+
+class TestShapesPydanticGivesTheRendering:
+    """Wherever pydantic reshapes a value, the python-mode dump that guides 
the ordering is reshaped alike.
+
+    Aliases a model's config gives a stdlib dataclass, a serializer nested in 
an
+    annotation, a computed field over excluded fields, a deque: each renders 
the same
+    way in both modes, so a set is sorted where it rendered and nothing else 
moves.
+    """
+
+    def test_iterator_in_a_deque_is_refused_and_left_unread(self):
+        windows = _Windows.model_validate({"windows": [[1, 2, 3]]})
+
+        assert fingerprint_tool_call("total", {"w": windows}, "id1") is None
+        assert (
+            fingerprint_model_request("m", _with_tool_return(windows), None, 
ModelRequestParameters()) is None
+        )
+        assert list(windows.windows[0]) == [1, 2, 3]
+
+    def test_set_in_a_deque_is_ordered(self):
+        assert _render({"r": _RecentTags(recent=deque([frozenset(_MANY)]))}) 
== {
+            "r": {"recent": [sorted(_MANY)]}
+        }
+
+    def test_set_in_a_dataclass_a_model_config_renames_is_ordered(self):
+        value = _HoldsCamelTags(tags=_CamelTags(user_tags=set(_MANY)))
+
+        assert _render({"h": value}) == {"h": {"tags": {"userTags": 
sorted(_MANY)}}}
+
+    def 
test_none_and_nan_keys_that_collide_in_the_message_dump_are_refused(self):
+        messages = _with_tool_return({None: 4, math.nan: 2, 1.5: 3})
+
+        assert fingerprint_model_request("m", messages, None, 
ModelRequestParameters()) is None
+
+    def test_computed_list_over_an_excluded_set_is_not_sorted(self):
+        """The computed list renders under the excluded set's key, so its 
order must still count."""
+
+        def fingerprint(ranking):
+            return fingerprint_tool_call(
+                "t", {"q": _RankedOverExcluded(tags={"x", "y"}, 
ranking=ranking)}, "id1"
+            )
+
+        assert fingerprint(["x", "y"]) != fingerprint(["y", "x"])
+
+    def test_back_link_in_private_state_does_not_cost_the_fingerprint(self):
+        """Rendering never reads private state, so a parent pointer there is 
no cycle."""
+        root = _Folder(name="root", children=[_Folder(name="child")])
+        root.children[0]._parent = root
+        messages = _with_tool_return(root)
+
+        assert fingerprint_model_request("m", messages, None, 
ModelRequestParameters()) == (
+            _json_mode_reference("m", messages, ModelRequestParameters())
+        )
+
+    def test_cached_computed_field_is_not_left_on_the_tool_argument(self):
+        """A tool that derives an updated copy must not inherit a total 
computed while fingerprinting."""
+        cart = _Cart(prices=[10.0, 2.5])
+
+        assert fingerprint_tool_call("quote", {"cart": cart}, "id1") is not 
None
+        assert "total" not in cart.__dict__
+        assert cart.model_copy(update={"prices": [10.0, 2.5, 5.0]}).total == 
17.5
+
+    def test_getter_that_reads_an_iterator_field_leaves_it_unread(self):
+        upload = _Upload(rows=[1, 2, 3, 4])
+
+        assert fingerprint_tool_call("load", {"upload": upload}, "id1") is None
+        assert list(upload.rows) == [1, 2, 3, 4]
+
+    def 
test_iterator_a_dataclass_computed_field_caches_is_refused_and_left_unread(self):
+        tool_arg = _PendingBatch(ids=[1, 2, 3])
+        returned = _PendingBatch(ids=[1, 2, 3])
+
+        assert fingerprint_tool_call("total", {"batch": tool_arg}, "id1") is 
None
+        assert "pending" not in tool_arg.__dict__
+        assert (
+            fingerprint_model_request("m", _with_tool_return(returned), None, 
ModelRequestParameters())
+            is None
+        )
+        assert list(returned.pending) == [1, 2, 3]
+
+    @pytest.mark.parametrize(
+        "value",
+        [
+            pytest.param(_JsonOnlyLonger(tags=set(_MANY), rows=["b", "a"]), 
id="json-only-longer"),
+            pytest.param(_JsonOnlyRenamed(tags=set(_MANY)), 
id="json-only-renamed"),
+        ],
+    )
+    def test_set_a_json_only_serializer_reshapes_is_refused(self, value):
+        """Python mode skips a json-only serializer, so the set is not found 
where it rendered."""
+        with pytest.raises(TypeError, match="cannot be found in its JSON 
rendering"):
+            _render({"q": value})
+
+        assert fingerprint_tool_call("t", {"q": value}, "id1") is None
+
+    def test_key_a_json_only_serializer_drops_is_not_a_collision(self):
+        """Python mode keeps the key, so only a key that is not a string can 
mean a collision."""
+        value = _JsonOnlyRedacted(headers={"authorization": "secret", 
"accept": "json"}, tags=set(_MANY))
+
+        assert _render({"q": value}) == {"q": {"headers": {"accept": "json"}, 
"tags": sorted(_MANY)}}
+
+    def 
test_nested_serializer_that_renames_and_drops_keys_is_not_a_collision(self):
+        """An int-keyed dict elsewhere turns the check on, and the renamed 
dict must still pass it."""
+        job = _Job(options={"max_retries": 3, "retry_delay": None})
+        messages = _with_tool_return({"job": job, "counts": {2025: 3}})
+
+        assert fingerprint_model_request("m", messages, None, 
ModelRequestParameters()) == (
+            _json_mode_reference("m", messages, ModelRequestParameters())
+        )
+
+
[email protected](frozen=True)
+class _Point:
+    x: int
+
+
+class _FrozenRegion(pydantic.BaseModel):
+    model_config = pydantic.ConfigDict(frozen=True)
+
+    name: str
+
+
+class _SalesByRegion(pydantic.BaseModel):
+    by_region: dict[_FrozenRegion, float]
+
+
+class TestValuesPythonModeCannotDump:
+    """A set of models or a dict keyed by one has no python-mode dump, and 
hashes as it always did.
+
+    Python mode turns the member or the key into a dict, which cannot be 
hashed, on
+    the pydantic versions that dump a key at all; older ones keep the key 
object.
+    Either way the message history must hash to what an earlier version stored.
+    """
+
+    @pytest.mark.parametrize(
+        "content",
+        [
+            pytest.param(frozenset({_Point(1), _Point(2), _Point(3)}), 
id="frozenset-of-frozen-dataclasses"),
+            pytest.param(
+                frozenset({_FrozenRegion(name="eu"), 
_FrozenRegion(name="us")}),
+                id="frozenset-of-frozen-models",
+            ),
+            pytest.param(
+                _SalesByRegion(by_region={_FrozenRegion(name="eu"): 1.5, 
_FrozenRegion(name="us"): 2.5}),
+                id="dict-keyed-by-a-frozen-model",
+            ),
+        ],
+    )
+    def test_tool_return_hashes_as_before(self, content):
+        messages = _with_tool_return(content)
+
+        fingerprint = fingerprint_model_request("m", messages, None, 
ModelRequestParameters())
+
+        assert fingerprint is not None
+        assert fingerprint == _json_mode_reference("m", messages, 
ModelRequestParameters())
+
+    def test_tool_argument_is_still_refused(self):
+        """Tool arguments never fingerprinted with one of these, so there is 
nothing stored to match."""
+        assert fingerprint_tool_call("t", {"points": frozenset({_Point(1), 
_Point(2)})}, "id1") is None
+
+
+class _SwappedFields(pydantic.BaseModel):
+    tags: Any
+    rows: list[Any]
+
+    @pydantic.model_serializer(mode="wrap", when_used="json")
+    def _swap(self, handler: Any) -> dict[str, Any]:
+        data = handler(self)
+        return {"tags": data["rows"], "rows": data["tags"]}
+
+
+class _ReversedTags(pydantic.BaseModel):
+    tags: set[str]
+
+    @pydantic.field_serializer("tags", when_used="json")
+    def _reverse(self, tags: set[str]) -> list[str]:
+        return sorted(tags, reverse=True)
+
+
+class _NonZeroCounts(pydantic.BaseModel):
+    counts: dict[int, int]
+
+    @pydantic.field_serializer("counts", when_used="json")
+    def _drop_zero(self, counts: dict[int, int]) -> dict[int, int]:
+        return {key: count for key, count in counts.items() if count}
+
+
+class _SortedIntKeys(pydantic.BaseModel):
+    data: dict[int, Any]
+
+    @pydantic.field_serializer("data", when_used="json")
+    def _sort(self, data: dict[int, Any]) -> dict[int, Any]:
+        return dict(sorted(data.items()))
+
+
+class _NoteAddedBack(pydantic.BaseModel):
+    tags: set[int]
+    note: str = pydantic.Field(exclude=True)
+
+    @pydantic.model_serializer(mode="wrap", when_used="json")
+    def _add_note(self, handler: Any) -> dict[str, Any]:
+        return {**handler(self), "note": self.note}
+
+
+class _Blobs(pydantic.BaseModel):
+    blobs: set[bytes]
+
+
+class _HexBlobs(pydantic.BaseModel):
+    model_config = pydantic.ConfigDict(ser_json_bytes="hex")
+
+    blobs: set[bytes]
+
+
+class _OptionsAsPairs(pydantic.BaseModel):
+    options: dict[str, Any]
+
+    @pydantic.field_serializer("options", when_used="json")
+    def _pairs(self, options: dict[str, Any]) -> list[Any]:
+        return list(options.items())
+
+
+class _GroupsWithTotal(pydantic.BaseModel):
+    groups: list[Any]
+
+    @pydantic.field_serializer("groups", when_used="json")
+    def _add_total(self, groups: list[Any]) -> list[Any]:
+        return [*groups, len(groups)]
+
+
+class TestListsThatDidNotComeFromTheSet:
+    """A list is sorted only where it holds the members of the set found at 
its place.
+
+    A serializer that applies only in JSON mode can put anything where python 
mode
+    has a set. Sorting what it put there would hide an order that is data.
+    """
+
+    def 
test_list_a_json_only_serializer_moves_to_a_set_field_keeps_its_order(self):
+        def fingerprints(rows):
+            value = _SwappedFields(tags={7, 8, 9}, rows=rows)
+            messages = _with_tool_return(value)
+            return (
+                fingerprint_tool_call("t", {"q": value}, "id1"),
+                fingerprint_model_request("m", messages, None, 
ModelRequestParameters()),
+            )
+
+        tool, model = fingerprints([1, 2, 3])
+        tool_reordered, model_reordered = fingerprints([3, 2, 1])
+
+        assert None not in (tool, model, tool_reordered, model_reordered)
+        assert tool != tool_reordered
+        assert model != model_reordered
+
+    def test_moved_set_whose_order_follows_the_hash_seed_is_refused(self):
+        value = _SwappedFields(tags=set(_MANY), rows=["x", "y"])
+
+        assert fingerprint_tool_call("t", {"q": value}, "id1") is None
+        assert (
+            fingerprint_model_request("m", _with_tool_return(value), None, 
ModelRequestParameters()) is None
+        )
+
+    def test_moved_set_of_integers_hashes_as_before(self):
+        messages = _with_tool_return(_SwappedFields(tags={7, 8, 9}, rows=[3, 
1, 2]))
+
+        assert fingerprint_model_request("m", messages, None, 
ModelRequestParameters()) == (
+            _json_mode_reference("m", messages, ModelRequestParameters())
+        )
+
+    def 
test_json_only_serializer_that_returns_the_members_is_sorted_like_the_set(self):
+        """Its list cannot be told from the set's own rendering, so its order 
does not count."""
+        assert _render({"q": _ReversedTags(tags={"z-first", "a"})}) == {"q": 
{"tags": ["a", "z-first"]}}
+
+    def test_members_of_different_shapes_are_refused(self):
+        mixed = {"a", "b", frozenset({"x", "y"})}
+
+        assert _member_template(mixed) is None
+        assert fingerprint_tool_call("t", {"groups": mixed}, "id1") is None
+        assert (
+            fingerprint_model_request("m", _with_tool_return(mixed), None, 
ModelRequestParameters()) is None
+        )
+
+    def test_integer_members_of_different_shapes_hash_as_before(self):
+        mixed = {1, 2, frozenset({3, 4})}
+        messages = _with_tool_return(mixed)
+
+        assert _member_template(mixed) is None
+        assert fingerprint_model_request("m", messages, None, 
ModelRequestParameters()) == (
+            _json_mode_reference("m", messages, ModelRequestParameters())
+        )
+
+    def test_member_rendered_longer_than_its_template_is_left_alone(self):
+        rendered = ["a", ["c", "b"], "extra"]
+
+        assert _apply_template(("sequence", (_LEAF, ("set", _LEAF))), 
rendered) is rendered
+        assert _apply_template(("sequence", (_LEAF, ("set", _LEAF))), ["a", 
["c", "b"]]) == ["a", ["b", "c"]]
+
+    def test_field_a_json_only_serializer_adds_back_still_counts(self):
+        def fingerprint(note):
+            return fingerprint_tool_call("t", {"q": _NoteAddedBack(tags={7, 8, 
9}, note=note)}, "id1")
+
+        assert fingerprint("DROP TABLE a") is not None
+        assert fingerprint("DROP TABLE a") != fingerprint("DROP TABLE b")
+
+    def test_set_of_bytes_in_a_model_is_ordered(self):
+        """Bytes in a model render as UTF-8 text by default, and outside one 
as base64."""
+        assert _render({"q": _Blobs(blobs={b"b", b"c", b"a"})}) == {"q": 
{"blobs": ["a", "b", "c"]}}
+        assert _render({"blobs": {b"b", b"\xff", b"a"}}) == {"blobs": ["YQ==", 
"Yg==", "_w=="]}
+        assert _render({"q": _HexBlobs(blobs={b"b", b"\xff", b"a"})}) == {"q": 
{"blobs": ["61", "62", "ff"]}}
+
+    def test_dict_a_json_only_serializer_renders_as_a_list_is_not_paired(self):
+        assert fingerprint_tool_call("t", {"q": 
_OptionsAsPairs(options={"tags": set(_MANY)})}, "id1") is None
+        assert (
+            fingerprint_tool_call("t", {"q": _OptionsAsPairs(options={"ids": 
{3, 1, 2}})}, "id1") is not None
+        )
+
+    def test_set_in_tool_definition_metadata_is_ordered(self):
+        def fingerprint(scopes):
+            tool = ToolDefinition(name="search", parameters_json_schema={}, 
metadata={"scopes": scopes})
+            params = ModelRequestParameters(function_tools=[tool])
+            return fingerprint_model_request("m", make_messages(), None, 
params)
+
+        assert fingerprint(set(_MANY)) is not None
+        assert fingerprint(set(_MANY)) == fingerprint(sorted(_MANY))
+
+    def 
test_list_a_json_only_serializer_lengthens_is_not_paired_by_position(self):
+        """The extra item means no item is known to be the rendering of the 
set next to it."""
+        assert fingerprint_tool_call("t", {"q": 
_GroupsWithTotal(groups=[set(_MANY)])}, "id1") is None
+        assert _render({"q": _GroupsWithTotal(groups=[{3, 1, 2}])}) == 
to_jsonable_python(
+            {"q": _GroupsWithTotal(groups=[{3, 1, 2}])}
+        )
+
+    @pytest.mark.parametrize(
+        ("value", "template"),
+        [
+            pytest.param(set(), _LEAF, id="empty-set"),
+            pytest.param({"a", "b"}, _LEAF, id="strings"),
+            pytest.param({_Scope.READ}, ("set", _LEAF), 
id="enum-member-holding-a-set"),
+            pytest.param(
+                {(1, frozenset({"a"})), (2, frozenset({"b", "c"}))},
+                ("sequence", (_LEAF, ("set", _LEAF))),
+                id="tuples-holding-sets",
+            ),
+            pytest.param({(1, "a"), (2, "b")}, _LEAF, id="tuples-of-leaves"),
+        ],
+    )
+    def test_member_template_says_where_the_sets_in_a_member_are(self, value, 
template):
+        assert _member_template(value) == template
+
+    def test_member_that_dumps_to_a_dict_has_no_template(self):
+        """Python mode refuses a set of models, so nothing is known about a 
dict member."""
+        assert _template({"a": 1}) is None
+        assert _template((1, {"a": 1})) is None
+
+    def test_sets_inside_tuple_members_are_ordered(self):
+        groups = {(1, frozenset({"b", "a", "c"})), (2, frozenset({"e", "d"}))}
+
+        assert _render({"groups": groups}) == {"groups": [[1, ["a", "b", 
"c"]], [2, ["d", "e"]]]}
+
+    def test_set_holding_nan_is_ordered_in_the_history(self):
+        """A NaN hashes by identity, so a set holding one iterates differently 
in every run."""
+        assert _seed_free(1.5)
+        assert not _seed_free(math.nan)
+        assert not _seed_free((1, math.nan))
+        assert fingerprint_model_request(
+            "m", _with_tool_return({math.nan, 7.0}), None, 
ModelRequestParameters()
+        ) == fingerprint_model_request(
+            "m", _with_tool_return([7.0, math.nan]), None, 
ModelRequestParameters()
+        )
+
+    @pytest.mark.parametrize(
+        ("guide", "refused"),
+        [
+            pytest.param({"tags": {"a", "b"}}, True, id="strings"),
+            pytest.param([{"tags": [(1, "a"), {(1, "b"), (2, "c")}]}], True, 
id="tuples-holding-strings"),
+            pytest.param({"scope": _Scope.READ}, True, id="enum-value"),
+            pytest.param({"tags": {"only"}}, False, id="single-member"),
+            pytest.param({"ids": {3, 1, 2}, "pairs": {(1, 2.5), (None, 
True)}}, False, id="numbers"),
+            pytest.param({"rows": ["b", "a"]}, False, id="no-set"),
+        ],
+    )
+    def test_unpaired_refuses_only_a_set_ordered_by_the_hash_seed(self, guide, 
refused):
+        rendered = object()
+
+        if refused:
+            with pytest.raises(TypeError, match="cannot be found in its JSON 
rendering"):
+                _unpaired(guide, rendered)
+        else:
+            assert _unpaired(guide, rendered) is rendered
+
+
+class _CountsByKey(pydantic.BaseModel):
+    counts: dict[bytes | str, int]
+
+
+class TestJsonOnlySerializersOnDictsWithOtherKeys:
+    """Keys that are not strings are rendered on their own, to see what a 
serializer did to the dict."""
+
+    def test_entries_a_json_only_serializer_drops_are_not_a_collision(self):
+        messages = _with_tool_return(_NonZeroCounts(counts={1: 0, 2: 5, 3: 0, 
4: 1}))
+
+        fingerprint = fingerprint_model_request("m", messages, None, 
ModelRequestParameters())
+
+        assert fingerprint is not None
+        assert fingerprint == _json_mode_reference("m", messages, 
ModelRequestParameters())
+
+    def test_reordered_entries_do_not_lend_a_set_to_a_neighbouring_list(self):
+        def fingerprint(order):
+            value = _SortedIntKeys(data={2: {5, 6, 7}, 1: order})
+            return fingerprint_model_request("m", _with_tool_return(value), 
None, ModelRequestParameters())
+
+        assert fingerprint(["y", "x", "w"]) is not None
+        assert fingerprint(["y", "x", "w"]) != fingerprint(["w", "x", "y"])
+
+    def test_reordered_entries_next_to_a_set_of_strings_are_refused(self):
+        value = _SortedIntKeys(data={2: set(_MANY), 1: ["y", "x"]})
+
+        assert (
+            fingerprint_model_request("m", _with_tool_return(value), None, 
ModelRequestParameters()) is None
+        )
+
+    def test_keys_that_render_alike_are_still_refused(self):
+        assert fingerprint_tool_call("t", {"d": {1: "a", "1": "b"}}, "id1") is 
None
+        assert (
+            fingerprint_model_request(
+                "m", _with_tool_return({1: "a", "1": "b"}), None, 
ModelRequestParameters()
+            )
+            is None
+        )
+
+    def test_bytes_and_str_keys_that_render_alike_in_a_model_are_refused(self):
+        """A model renders bytes as UTF-8, so ``b"a"`` and ``"a"`` become one 
key there."""
+        value = _CountsByKey(counts={b"a": 1, "a": 2})
+
+        assert (
+            fingerprint_model_request("m", _with_tool_return(value), None, 
ModelRequestParameters()) is None
+        )
+
+
+# Every case that could follow the hash seed, fingerprinted in one interpreter:
+# starting one imports Airflow, which takes seconds.
+_HASH_SEED_SNIPPET = """
+import dataclasses
+import enum
+import importlib.util
+import json
+import sys
+
+import pydantic
+from pydantic.alias_generators import to_camel
+from pydantic_ai.messages import ModelRequest, ToolReturnPart, UserPromptPart
+from pydantic_ai.models import ModelRequestParameters
+
+spec = importlib.util.spec_from_file_location("fp", sys.argv[1])
+mod = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(mod)
+
+TAGS = frozenset({"alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", 
"theta"})
+
+
+class Aliased(pydantic.BaseModel):
+    tags: set[str] = pydantic.Field(alias="labels")
+
+
+class Camel(pydantic.BaseModel):
+    model_config = pydantic.ConfigDict(alias_generator=to_camel, 
serialize_by_alias=True)
+    my_tags: set[str]
+
+
+class Scope(enum.Enum):
+    READ = TAGS
+    WRITE = frozenset({"iota", "kappa", "lambda", "mu"})
+
+
[email protected](frozen=True)
+class Point:
+    x: int
+
+
+def request(content, settings=None, params=None):
+    part = ToolReturnPart(tool_name="t", content=content, tool_call_id="c1")
+    return mod.fingerprint_model_request(
+        "m", [ModelRequest(parts=[part])], settings, params or 
ModelRequestParameters()
+    )
+
+
+prompt = [ModelRequest(parts=[UserPromptPart(content="hi")])]
+print(json.dumps({
+    "order": list(TAGS),
+    "plain set": mod._digest(mod._render({"tags": set(TAGS)})),
+    "set a tool returned": request(set(TAGS)),
+    "aliased and root model sets": mod.fingerprint_tool_call(
+        "t", {"a": Aliased(labels=set(TAGS)), "r": 
pydantic.RootModel[set[str]](set(TAGS))}, "c1"
+    ),
+    "generated alias and revealed tool names": request(
+        Camel(myTags=set(TAGS)),
+        params=ModelRequestParameters(revealed_tool_names={"search", "fetch", 
"summarize", "rank", "plan"}),
+    ),
+    "enum value in the history": request(Scope.READ),
+    "enum value and sets in a set": mod.fingerprint_tool_call(
+        "t", {"scope": Scope.READ, "groups": {TAGS, frozenset({"iota", 
"kappa", "lambda"})}}, "c1"
+    ),
+    "enum members holding sets, in a set": request({Scope.READ, Scope.WRITE}),
+    "tuples holding sets, in a set": request({("r", TAGS), ("w", 
frozenset({"iota", "kappa", "lambda"}))}),
+    "set in model settings": mod.fingerprint_model_request(
+        "m", prompt, {"extra_body": {"include": set(TAGS)}}, 
ModelRequestParameters()
+    ),
+    "set of frozen dataclasses": request(frozenset({Point(1), Point(2), 
Point(3)})),
+}))
+"""
+
+_HASH_SEEDS = ("0", "1")
+
+
[email protected]
+def _digests_under_hash_seed(seed: str) -> dict[str, Any]:
+    completed = subprocess.run(
+        [sys.executable, "-c", _HASH_SEED_SNIPPET, 
fingerprint_module.__file__],
+        capture_output=True,
+        text=True,
+        env={**os.environ, "PYTHONHASHSEED": seed, "PYTHONWARNINGS": "ignore"},
+        check=True,
+    )
+    return json.loads(completed.stdout.strip().splitlines()[-1])
+
+
+class TestDigestsAcrossHashSeeds:
+    """The real proof: fresh interpreters with different hash seeds must 
agree, as two attempts would.
+
+    In-process comparisons cannot catch a hash-seed dependency, since one 
process has
+    one seed.
+    """
+
+    def test_the_seeds_iterate_a_set_in_different_orders(self):
+        """Otherwise the cases below would pass with nothing ordered."""
+        first, second = (_digests_under_hash_seed(seed)["order"] for seed in 
_HASH_SEEDS)
+
+        assert sorted(first) == sorted(second)
+        assert first != second
+
+    @pytest.mark.parametrize(
+        "case",
+        [
+            "plain set",
+            "set a tool returned",
+            "aliased and root model sets",
+            "generated alias and revealed tool names",
+            "enum value in the history",
+            "enum value and sets in a set",
+            "enum members holding sets, in a set",
+            "tuples holding sets, in a set",
+            "set in model settings",
+            "set of frozen dataclasses",
+        ],
+    )
+    def test_digest_does_not_depend_on_the_hash_seed(self, case):
+        first, second = (_digests_under_hash_seed(seed)[case] for seed in 
_HASH_SEEDS)
+
+        assert first is not None
+        assert first == second

Reply via email to