feat: citation content - #1068
Conversation
This is a gpt-5.4 issue, not an ellmer/shinychat bug. After #1071, this should (mostly) go away. The boxes are ChatGPT's internal citation markup (private-use codepoints only its own frontend renders) leaking through the API. It happens only when the model skips calling Tested the lineup on the same prompt: only full-size gpt-5.4 does it, 4/4 runs. 4.1, 5, 5.1, 5.2, 5.5, all three 5.6s, and 5.4-mini/nano all search properly with zero markers. 5.4 just happens to be our current default, hence the sighting. Both new defaults in #1071 ( So: merge #1071, no code change needed here. |
thisisnic
left a comment
There was a problem hiding this comment.
Tried it in ellmer and in the shinychat example from the associate PR, and looks good!
Only comment from experimenting with different things is that when I was playing around with claude_tool_web_search() and then claude_tool_web_fetch(), the latter citations have @source = NULL - given that the URL is there on the ContentToolResponseFetch object in the same turn, we could perhaps carry it through to the ContentCitation?
Unsure if this is a slightly contrived use case or not though as these are fundamentally different tools and source could mean slightly different things in each, and might be a bit of a pain to implement, so I'm not sure it's necessary.
library(ellmer)
# Web fetch
chat <- chat_anthropic()
#> Using model = "claude-sonnet-4-6".
chat$register_tool(claude_tool_web_fetch(citations = TRUE))
chat$chat("Summarize https://example.com")
#> Here is a summary of [example.com](https://example.com):
#>
#> **Example Domain** is a website designated for use in illustrative
#> documentation examples, and does not require permission to use for that
#> purpose. However, it is not intended for use in actual operations. More
#> information can be found at the [IANA Domains Example
#> page](https://iana.org/domains/example).
turn <- chat$get_turns()[[2]]
citations <- purrr::keep(turn@contents, \(x) inherits(x,"ellmer::ContentCitation"))
citations[[1]]
#> <ellmer::ContentCitation>
#> @ source : NULL
#> @ grounded_span: chr "**Example Domain** is a website designated for use in illustrative documentation examples, and does not require"| __truncated__
#> @ cited_quote : chr "---\nmeta-viewport: width=device-width, initial-scale=1\ntitle: Example Domain\n---\n\n# Example Domain\n\nThis"| __truncated__
#> @ extra :List of 6
#> .. $ type : chr "char_location"
#> .. $ cited_text : chr "---\nmeta-viewport: width=device-width, initial-scale=1\ntitle: Example Domain\n---\n\n# Example Domain\n\nThis"| __truncated__
#> .. $ document_index : int 0
#> .. $ document_title : chr "Example Domain"
#> .. $ start_char_index: int 0
#> .. $ end_char_index : int 177
# Web search
chat <- chat_anthropic()
#> Using model = "claude-sonnet-4-6".
chat$register_tool(claude_tool_web_search())
chat$chat("Summarize https://example.com")
#> Here's a summary of **example.com**:
#>
#> **example.com** is a reserved domain name in the Domain Name System (DNS) of
#> the Internet, reserved by the Internet Assigned Numbers Authority (IANA) at the
#> direction of the Internet Engineering Task Force (IETF) as a special-use domain
#> name for documentation purposes. [1]
#>
#> The domain is used widely in books, tutorials, sample network configurations,
#> and generally as examples for the use of domain names. [1]
#>
#> It is intended for general use in any kind of documentation, such as technical
#> and software documentation, manuals, and sample software configurations. This
#> allows documentation writers to select a domain name without creating naming
#> conflicts if end-users try to use the sample configurations or examples
#> verbatim. The domain may be used in documentation without prior consultation
#> with IANA or ICANN. [1]
#>
#> It has been used since 1999 as a placeholder in documentation, tutorials,
#> sample network configurations, or to prevent accidental references to real
#> websites. [2]
#>
#> In short, **example.com** is not a real website in the traditional sense — it
#> is a purposefully reserved placeholder domain maintained for safe and
#> conflict-free use in documentation and examples.
#>
#> Sources
#> [1] Example.com
#> https://en.wikipedia.org/wiki/Example.com
#> [2] Typo traps: analyzing traffic to exmaple.com (or is it example.com?) | The
#> Cloudflare Blog
#>
#> https://blog.cloudflare.com/typo-traps-analyzing-traffic-to-exmaple-com-or-is-it-example-com/
turn <- chat$get_turns()[[2]]
citations <- purrr::keep(turn@contents, \(x) inherits(x, "ellmer::ContentCitation"))
citations[[1]]
#> <ellmer::ContentCitation>
#> @ source : <ellmer::WebSource>
#> .. @ url : chr "https://en.wikipedia.org/wiki/Example.com"
#> .. @ title: chr "Example.com"
#> @ grounded_span: chr "**example.com** is a reserved domain name in the Domain Name System (DNS) of the Internet, reserved by the Inte"| __truncated__
#> @ cited_quote : chr "example.com\n- Type of site: Reserved domain\n- Available in: English\n- Owner: Internet Assigned Numbers Autho"| __truncated__
#> @ extra :List of 5
#> .. $ type : chr "web_search_result_location"
#> .. $ cited_text : chr "example.com\n- Type of site: Reserved domain\n- Available in: English\n- Owner: Internet Assigned Numbers Autho"| __truncated__
#> .. $ url : chr "https://en.wikipedia.org/wiki/Example.com"
#> .. $ title : chr "Example.com"
#> .. $ encrypted_index: chr "EpEBCioIEhgCIiQ2N2JiOGZlYi03YmNhLTQzNjktOWUwNC0zMzlkNzM2NDZiNDASDGXHeRiiNuNouTSgGhoMSr53xmgZhh8bqHApIjBgNkaOGjg"| __truncated__| test_that("citation and source classes are exported", { | ||
| exports <- getNamespaceExports("ellmer") | ||
| expect_true( | ||
| all( | ||
| c( | ||
| "Source", | ||
| "WebSource", | ||
| "ContentCitation" | ||
| ) %in% | ||
| exports | ||
| ) | ||
| ) | ||
| }) |
There was a problem hiding this comment.
This test seems a little out of keeping with the others, just to check, what's the reason for it?
…-model # Conflicts: # NAMESPACE # R/provider-claude.R

Overview
When a model answers using web search or fetch tools, ellmer now surfaces which sources back the answer and which words each source grounds, normalized across OpenAI, Anthropic, and Google. Grounded answers carry their citations through to turns uniformly, both progressively during
stream = "content"and on the final turn.Motivation
Citation information used to be dropped: OpenAI annotations were discarded, Anthropic citation deltas never reached normalized content, and Google's grounding metadata was ignored entirely. Each provider exposes this information differently, so there was no portable way to build a UI that shows sources.
This PR gives ellmer one citation model so a downstream consumer, notably Shiny Chat, can render inline citation pills, a message-level Sources control, web-search activity, and fetch status without touching raw provider payloads. It brings ellmer in line with chatlas's citation content model and unblocks the R support described in posit-dev/shinychat#280.
Public API
ContentCitationis a first-class content type representing a source that grounds part of an assistant's answer:sourceidentifies the evidence with a typedSource, or isNULLwhen the provider supplies no resolvable source.grounded_spanis the answer-side text the citation supports, suitable for attaching a footnote marker or highlight.cited_quoteis the source-side evidence quoted by the provider, when available.extraretains the provider-specific citation payload.Sourceidentifies a piece of evidence. Today its concrete subtype isWebSource, with a URL and optional title; document or RAG sources can be added later without redesigningContentCitation.ContentToolRequestSearch/ContentToolResponseSearchContentToolRequestFetch/ContentToolResponseFetchContentToolResponseSearchnow carriesWebSourceobjects rather than bare URLs, and fetch responses include a normalized success/error status.Behavior
ContentCitationrecords are placed beside the text they ground in a turn's contents.stream = "content", citations and web activity are yielded alongside text;stream = "text"continues to yield text only.contents_record()andcontents_replay()retain the new content types.echo = "all"also summarizes web-search and fetch activity.Runnable example
This app requires an
ANTHROPIC_API_KEY. Web search must be enabled for theAnthropic organization. Save it as
app.R, then run it from an ellmer andshinychat checkout where both development packages are installed.
R Shiny app
Testing
Closes #775