Replay citations in message history to the provider that produced them
Merged
AD
9d ago+1823+1823+0123456789012345678901234567890123456789-13-13-01234567890123456789
When message history goes back to the same provider that produced it, citations that carry their own identity go back with their text. Everything else goes back as plain text, as it does today.
How it works, at a glance
saved TextPart(content, citations, provider_details={'citations': [raw entries]})
└─ next request to the same provider
├─ citation identifies its own source, and still fits the text? ──► rebuilt from the raw entry and sent back
└─ anything else (documents, other providers, edited text) ──► text sent as plain text
| Provider | Sent back | Only when |
|---|---|---|
| Anthropic | Web search citations | The client supports web search (Bedrock's doesn't, and rejects them with a 400), and each citation still covers the whole text part. Each carries its own encrypted_index, which Anthropic checks |
| OpenAI Responses | URL and file annotations | The original message ID is sent back, which is the default for reasoning models. A URL annotation's range must still cover the link to its URL. File citations go back as returned, with their file_id |
Document citations on Anthropic (char_location, which also covers Web Fetch pages) and on Bedrock (documentChar) are not sent back; their text goes back as plain text. #11054 picks them up.
This changes requests for anyone using web search or file search, not only Citations() users, since these citations come back without asking. Anthropic and OpenAI accept them, including when the search tool or its results are no longer in the request, and when a cited file was deleted (checked live).
Decisions
- Same provider only. Sending citations to a different provider is left for a follow-up, until we can show it changes what the model knows.
- Only citations that identify their own source. A document citation names its document only by its position among the request's documents. Once history is trimmed, compacted or edited, that position can belong to a different document, and Anthropic and Bedrock accept such a citation without error (probed 2026-10-21/22). Matching on the cited text can't rule this out either: two documents with the same text at the same range look identical. A wrong citation is worse than none, so document citations go back as plain text until replay can record which document was cited (#11054). Other frameworks that send these citations back unchanged hit the same problem: LibreChat-AI/agents#586 fails with
Invalid document indexafter summarising. - Never invent provider data. Replay rebuilds each citation from the provider's raw entry, not from
Citationfields, and sends only the fields the request accepts. No IDs or tokens are made up. A citation that can't be checked goes back as plain text, and so does one with an empty URL or file ID, which OpenAI rejects with a 400 (checked live). - OpenAI URL annotations must still cover their link. OpenAI accepts an annotation whose range has moved or runs past the text (probed 2026-10-22), so nothing on OpenAI's side catches one that edited history has moved. In every recorded annotation, the range covers the link the model wrote, which holds the URL. So replay sends one back only while it still does.
- Anthropic citations go back only while they still cover their text. Anthropic cites whole text parts, so each citation's anchor is the whole part. If the text was edited, or joined with other text by the Vercel frontend, the anchors no longer match and the part goes back as plain text. Live testing found Anthropic accepts old citations on edited text without error. An edit that keeps the same length can't be detected, so the docs say to clear a part's citations when editing its text.
- Not to clients without web search.
AnthropicModelon the Bedrock client rejectsweb_search_result_locationwith a 400 (found live on 2026-10-23, on history whose search parts a processor removed), so web search citations go back only where the profile lists web search. - Stored history doesn't change. Only the outgoing request does.
- Prompt caching. Probed live on 2026-10-22 on all three APIs: history that always goes back with its citations, and history that always goes back without them, both keep the cached prefix. The cache only breaks when the same answer is sent with citations on one turn and without them on the next. Replay decides per citation, from that answer alone, so the result doesn't change from turn to turn unless the history before the answer changes.
Verification
- Two-turn recordings for Anthropic web search and OpenAI Responses: the first response is saved as JSON, loaded, and sent back, and the second request is matched on its body.
- OpenAI Responses, file citation: a recording sends a file citation back with its filename.
- Live acceptance runs on 2026-10-22 and 2026-10-23: saved history continued on Anthropic and OpenAI Responses, sent to another provider, edited, round-tripped through the Vercel adapter, and sent to Claude on Bedrock through
AnthropicModel. - Unit tests build the request without a client. They pin that only valid web search citations go back to Anthropic, while document citations and other providers' citations go back as plain text, and that OpenAI annotations that can't be rebuilt from the raw data are left out.
Checklist
- Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it.
- No breaking changes in accordance with the version policy.
- Any permitted compatibility impact has a label, warning, migration, release note, and exact API-check waiver.
- PR title is fit for the release changelog.