项目文件夹

文件
Simon Willison 3bf5606b5a DAG phase 1: canonical message serialization + content hash
Adds llm/_canonical.py with canonical_message_json() and
message_content_hash(). The serialization is the hash contract for the
incoming DAG-shaped message store; snapshot tests in
tests/test_canonical.py pin the wire format.

The include_provider_metadata parameter is threaded through now so a
future semantic_hash column can be added without refactoring
(see plans/dag-provider-metadata-hashing.md).

Also commits the design docs:
- plans/dag-schema.md — full DAG storage design
- plans/dag-provider-metadata-hashing.md — Option 1 now, Option 2 later

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:12:45 -07:00

200 行
7.0 KiB
Python

"""Tests for canonical message serialization and content hashing.
The hash is a contract. Once shipped, changing canonicalization breaks
dedup forever. These snapshot tests pin the wire format — any change that
perturbs them must be a deliberate, versioned migration.
"""
import hashlib
import json
import pytest
from llm._canonical import (
canonical_message_json,
message_content_hash,
)
from llm.parts import (
AttachmentPart,
Message,
ReasoningPart,
TextPart,
ToolCallPart,
ToolResultPart,
)
from llm.models import Attachment
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
class TestCanonicalMessageJson:
def test_text_only_user_message(self):
msg = Message(role="user", parts=[TextPart(text="Hello")])
out = canonical_message_json(msg)
assert out == b'{"parts":[{"text":"Hello","type":"text"}],"role":"user"}'
def test_keys_are_sorted_recursively(self):
msg = Message(
role="assistant",
parts=[TextPart(text="hi")],
provider_metadata={"z": 1, "a": {"y": 2, "b": 3}},
)
out = canonical_message_json(msg).decode("utf-8")
# Each object's keys must be in sorted order.
parsed = json.loads(out)
assert list(parsed.keys()) == sorted(parsed.keys())
assert list(parsed["provider_metadata"].keys()) == ["a", "z"]
assert list(parsed["provider_metadata"]["a"].keys()) == ["b", "y"]
def test_empty_provider_metadata_is_omitted(self):
msg_none = Message(role="user", parts=[TextPart(text="x")])
msg_empty = Message(
role="user", parts=[TextPart(text="x")], provider_metadata={}
)
# Both should hash identically — empty == missing.
assert canonical_message_json(msg_none) == canonical_message_json(msg_empty)
assert b"provider_metadata" not in canonical_message_json(msg_none)
def test_no_whitespace_in_output(self):
msg = Message(role="user", parts=[TextPart(text="hi")])
out = canonical_message_json(msg)
assert b", " not in out
assert b": " not in out
def test_unicode_not_escaped(self):
msg = Message(role="user", parts=[TextPart(text="café ☕")])
out = canonical_message_json(msg)
# ensure_ascii=False keeps the raw UTF-8 bytes.
assert "café ☕".encode("utf-8") in out
def test_include_provider_metadata_false_omits_it(self):
msg = Message(
role="assistant",
parts=[TextPart(text="hi", provider_metadata={"sig": "abc"})],
provider_metadata={"msg_id": "xyz"},
)
with_pm = canonical_message_json(msg, include_provider_metadata=True)
without_pm = canonical_message_json(msg, include_provider_metadata=False)
assert b"provider_metadata" in with_pm
assert b"provider_metadata" not in without_pm
assert b"sig" not in without_pm
assert b"msg_id" not in without_pm
def test_all_part_types_round_trip_into_canonical(self):
msg = Message(
role="assistant",
parts=[
TextPart(text="answer"),
ReasoningPart(text="thinking", token_count=12),
ToolCallPart(
name="search",
arguments={"q": "weather"},
tool_call_id="call_1",
),
ToolResultPart(
name="search",
output="sunny",
tool_call_id="call_1",
),
AttachmentPart(
attachment=Attachment(type="image/png", content=b"\x00\x01\x02")
),
],
)
out = canonical_message_json(msg)
parsed = json.loads(out)
part_types = [p["type"] for p in parsed["parts"]]
assert part_types == [
"text",
"reasoning",
"tool_call",
"tool_result",
"attachment",
]
def test_attachment_content_is_standard_base64(self):
msg = Message(
role="user",
parts=[AttachmentPart(attachment=Attachment(content=b"\xff\xfe\xfd"))],
)
out = canonical_message_json(msg).decode("utf-8")
# Standard base64 with padding.
assert '"content":"//79"' in out
class TestMessageContentHash:
def test_identical_messages_hash_equal(self):
a = Message(role="user", parts=[TextPart(text="hi")])
b = Message(role="user", parts=[TextPart(text="hi")])
assert message_content_hash(a) == message_content_hash(b)
def test_different_text_hashes_differently(self):
a = Message(role="user", parts=[TextPart(text="hi")])
b = Message(role="user", parts=[TextPart(text="bye")])
assert message_content_hash(a) != message_content_hash(b)
def test_provider_metadata_affects_hash_by_default(self):
a = Message(role="assistant", parts=[TextPart(text="x")])
b = Message(
role="assistant",
parts=[TextPart(text="x")],
provider_metadata={"sig": "abc"},
)
assert message_content_hash(a) != message_content_hash(b)
def test_snapshot_hash_text_only(self):
"""Pinned fixture: if this hash changes, dedup is broken for all
existing DBs. Treat failure as a contract-breaking signal."""
msg = Message(role="user", parts=[TextPart(text="Hello, world")])
assert (
message_content_hash(msg)
== _sha256(
b'{"parts":[{"text":"Hello, world","type":"text"}],"role":"user"}'
)
)
def test_snapshot_hash_with_provider_metadata(self):
msg = Message(
role="assistant",
parts=[TextPart(text="ok")],
provider_metadata={"beta": 2, "alpha": 1},
)
# Keys sorted: alpha before beta.
expected_json = (
b'{"parts":[{"text":"ok","type":"text"}],'
b'"provider_metadata":{"alpha":1,"beta":2},'
b'"role":"assistant"}'
)
assert canonical_message_json(msg) == expected_json
assert message_content_hash(msg) == _sha256(expected_json)
class TestFloatRejection:
def test_float_in_message_provider_metadata_raises(self):
msg = Message(
role="user",
parts=[TextPart(text="x")],
provider_metadata={"temp": 0.7},
)
with pytest.raises(TypeError, match="float"):
message_content_hash(msg)
def test_float_in_part_provider_metadata_raises(self):
msg = Message(
role="user",
parts=[TextPart(text="x", provider_metadata={"nested": {"f": 1.5}})],
)
with pytest.raises(TypeError, match="float"):
message_content_hash(msg)
def test_integers_are_fine(self):
msg = Message(
role="user",
parts=[TextPart(text="x")],
provider_metadata={"count": 42, "nested": {"n": 0}},
)
# Should not raise.
message_content_hash(msg)