Round-trip message timestamps through `VercelAIAdapter`'s `UIMessage.metadata`

Merged
GE
5mo ago
+562-39

Summary

Round-trip ModelResponse.timestamp (and ModelRequest.timestamp where present) through Vercel AI UIMessage.metadata, plus application-level metadata. Server-side fields (usage, model_name, provider_*, provider_response_id, finish_reason) are deliberately excluded — UIMessage.metadata is client-controlled, so dumping these would leak server infrastructure and loading them would trust client-submitted history for values the server owns (e.g. forging provider_response_id to chain OpenAI previous_response_id='auto' into another user's conversation).

Broadening the round-trip behind an explicit user-controlled opt-in is tracked in #5174 as additive follow-up work.

Changes

  • _PydanticAIMessageMetadata (private BaseModel, extra='ignore') under the reserved pydantic_ai key in UIMessage.metadata. Forward-compat: future fields can be added without a wire-protocol break.
  • VercelAIAdapter.dump_messages / load_messages round-trip timestamp plus application metadata. apply_message_metadata is conservative on the load side — empty/missing app-side metadata never clobbers prior message.metadata, which matters when consecutive UIMessages merge into the same ModelRequest.
  • VercelAIEventStream.handle_run_result emits a single message-metadata chunk per run before finish-step, keeping the AI SDK's merge semantics equivalent to assignment. Errored runs don't emit one (no AgentRunResultEvent to source timestamp from).
  • MessagesBuilder.checkpoint() / last_modified() + new BuilderCheckpoint dataclass — internal change-tracking the Vercel adapter uses to attribute streamed metadata to the right rebuilt message. Re-exported from pydantic_ai.ui.
  • Client-supplied instructions and any other field outside timestamp are silently dropped on load (matches the PR #4087 trust-filter precedent for SystemPromptParts).
  • Streaming dump-path asymmetry documented: streamed message-metadata chunks fire only for assistant responses, whereas dump_messages populates both request and response sides.

Fixes #5174.

Tests

  • tests/test_vercel_ai.py — full dump-load round-trip (test_adapter_dump_load_roundtrip_with_message_metadata), MessageMetadataChunk emission (test_event_stream_emits_message_metadata), single-chunk-per-run invariant in multi-step runs (test_event_stream_emits_single_message_metadata_per_run), instruction-injection rejection (test_adapter_load_rejects_client_supplied_instructions), malformed-payload rejection (test_adapter_load_ignores_malformed_pydantic_metadata), merged-ModelRequest app-metadata preservation (test_adapter_load_preserves_application_metadata_across_merged_messages), and ~5 more around edge cases (empty pydantic_ai block, no target message, app-only metadata, etc.). ~42 existing snapshots updated to use IsStr() matchers for runtime timestamps.