Skip to content

feat: citation content - #1068

Open
cpsievert wants to merge 9 commits into
mainfrom
feat/citation-content-model
Open

feat: citation content#1068
cpsievert wants to merge 9 commits into
mainfrom
feat/citation-content-model

Conversation

@cpsievert

@cpsievert cpsievert commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

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

  • ContentCitation is a first-class content type representing a source that grounds part of an assistant's answer:
    • source identifies the evidence with a typed Source, or is NULL when the provider supplies no resolvable source.
    • grounded_span is the answer-side text the citation supports, suitable for attaching a footnote marker or highlight.
    • cited_quote is the source-side evidence quoted by the provider, when available.
    • extra retains the provider-specific citation payload.
  • Source identifies a piece of evidence. Today its concrete subtype is WebSource, with a URL and optional title; document or RAG sources can be added later without redesigning ContentCitation.
  • Web activity content records provider-managed searches and fetches:
    • ContentToolRequestSearch / ContentToolResponseSearch
    • ContentToolRequestFetch / ContentToolResponseFetch

ContentToolResponseSearch now carries WebSource objects rather than bare URLs, and fetch responses include a normalized success/error status.

Behavior

  • ContentCitation records are placed beside the text they ground in a turn's contents.
  • With stream = "content", citations and web activity are yielded alongside text; stream = "text" continues to yield text only.
  • Completed turns preserve the same citation and web-activity records, so live rendering, history, and replay share one shape.
  • Citations are client-side metadata and are not sent back to providers. Native web-tool annotations are replayed only to the provider that produced them, keeping turns portable across providers.
  • contents_record() and contents_replay() retain the new content types.
  • Console echo now renders citation markers and a deduplicated source list; echo = "all" also summarizes web-search and fetch activity.

Runnable example

This app requires an ANTHROPIC_API_KEY. Web search must be enabled for the
Anthropic organization. Save it as app.R, then run it from an ellmer and
shinychat checkout where both development packages are installed.

R Shiny app
library(bslib)
library(ellmer)
library(shiny)
library(shinychat)

ui <- page_fillable(
  title = "Web citation rendering",
  chat_ui(
    "chat",
    height = "100%",
    placeholder = "Ask a question that needs current web sources...",
    enable_cancel = TRUE
  )
)

server <- function(input, output, session) {
  client <- chat_anthropic(
    system_prompt = paste(
      "You are a concise research assistant.",
      "Use web search for current facts and web fetch for supplied URLs.",
      "Cite every factual claim supported by a web source."
    )
  )
  client$register_tool(claude_tool_web_search())
  client$register_tool(claude_tool_web_fetch(citations = TRUE))

  chat_server(
    "chat",
    client,
    greeting = paste(
      "Ask me to research a current topic, or summarize a URL.",
      "Search activity, fetched pages, inline citations, and sources",
      "will appear in the response."
    ),
    history = FALSE
  )
}

shinyApp(ui, server)

Testing

  • Content-model tests for sources, citations, normalized fetch status, and record/replay.
  • Provider tests for OpenAI, Anthropic, and Google in both streaming and completed responses.
  • Cross-provider serialization tests that preserve native replay where supported and exclude incompatible provider annotations.
  • Console rendering tests for citation markers, source deduplication, web activity, and cancelled streams.

Closes #775

@cpsievert
cpsievert marked this pull request as ready for review July 31, 2026 23:11
@cpsievert
cpsievert requested a review from thisisnic July 31, 2026 23:11
@hadley

hadley commented Aug 3, 2026

Copy link
Copy Markdown
Member

I kicked the tires with chat_anthropic() and it looks good, but the equivalent shiny app for chat_openai() seems to have problems:

Screenshot 2026-08-03 at 09 14 57

@cpsievert

cpsievert commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

the equivalent shiny app for chat_openai() seems to have problems:

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 web_search and fabricates the refs — so it's a signal that the answer is unsourced, not a parsing problem on our end. There are many issues that have been filed across major projects also complaining of this problem, but taking no action ultimately since it's a model deficiency.

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 (gpt-5.6-terra, and gpt-5.4-nano for chat_openai_test()) are clean, 4/4.

So: merge #1071, no code change needed here.

@thisisnic thisisnic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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__

Comment on lines +76 to +88
test_that("citation and source classes are exported", {
exports <- getNamespaceExports("ellmer")
expect_true(
all(
c(
"Source",
"WebSource",
"ContentCitation"
) %in%
exports
)
)
})

@thisisnic thisisnic Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add content type for citations

3 participants