Skip to content

No grounded-search API returns a publisher name, and three more attribution gotchas

TL;DR.

Anthropic web_search, Gemini google_search, and OpenAI web_search all return url plus page title and no publisher field, so any pipeline printing an outlet name is deriving it from the domain. Brave News profile.name is the exception, Anthropic's citations are a narrow subset of results it already fetched, and domain filtering differs incompatibly across all three vendors.

If your pipeline prints an outlet name next to a citation, no grounded-search API is giving you that name. You are deriving it from the URL domain, whether you meant to or not. I went into a provider reassessment believing one of the major grounding APIs supplied per-article outlet names. None of them do. Here is what the response schemas actually contain, verified against recorded API responses rather than docs alone.

Context: a two-phase news pipeline. Phase A calls a search-capable model and keeps its prose plus citation metadata; Phase B extracts {headline, why_it_matters, source_name} and renders [source_name](url) publicly. When Phase B has no outlet in its input it invents one, so the metadata question is load-bearing.

Gemini's web.title is a bare domain, not a headline and not a display name

The widespread assumption is that Google's grounding index hands back recognizable outlets. It hands back domain strings. Real groundingChunks from gemini-3.1-pro-preview via generativelanguage v1alpha, tools=[{'googleSearch': {}}], nine chunks, web keys exactly ['title', 'uri']:

mdcounties.org        wikipedia.org         bnnbloomberg.ca
americandeposits.com  fool.com              nbcpalmsprings.com
smartsolar.com.tr     metatradingclub.com   post-gazette.com

Google's own docs agree: the Vertex example shows "title": "weatherbug.com", and the newer Interactions-API url_citation shows "title": "aljazeera.com". Two implications. First, web.title is informationally identical to urlparse(url).netloc, so switching to Gemini to "get outlet names" is a no-op. Second, note the actual domains: a grounding index surfacing smartsolar.com.tr and metatradingclub.com for macroeconomic queries is not a source-quality upgrade over anything.

Beware a second trap here: with several chunks from one outlet, web.title cannot discriminate between them at all, so story-to-chunk matching must use the resolved URL slug.

Anthropic returns far more than it cites, and litellm already exposes it

Anthropic's web_search_20250305 puts search results and citations in different places, and reading only the citations throws away most of the fetch. From one recorded /v1/messages response with four searches:

Where Count Distinct domains
web_search_tool_result blocks 31 results 23
citations on text blocks 11 7

The result pool was overwhelmingly primary sources (federalreserve.gov, bls.gov, cnbc.com, cnn.com, forbes.com, fortune.com, nerdwallet.com); the model cited a narrow slice of it. That reframes the usual complaint. When your citations land on a low-quality aggregator, the index may have offered better pages and the model simply did not cite them.

Field shapes, which differ between the two:

# web_search_tool_result -> content[] entries
{'type', 'url', 'title', 'encrypted_content', 'page_age'}
# citations on a text block
{'type', 'url', 'title', 'encrypted_index', 'cited_text'}

page_age exists only on search results, as "June 17, 2026" or "1 week ago" or null. If you fetch pages yourself just to date them, you may already have a date for free.

On litellm 1.93.0 both surfaces are available without touching the raw SDK:

psf = resp.choices[0].message.provider_specific_fields
citations = psf['citations']           # nested: one list per text block, flatten it
results   = psf['web_search_results']  # the full web_search_tool_result blocks

See litellm/llms/anthropic/chat/transformation.py (collection near line 2009, assignment near 2238) and litellm/llms/anthropic/chat/handler.py (streaming accumulation near 858).

Domain filtering is incompatible across the three vendors

This is the actual lever on source quality, and no two vendors agree on it:

Provider Allowlist Blocklist Both in one request
Anthropic web_search allowed_domains blocked_domains No, HTTP 400
Gemini google_search none exclude_domains n/a, no allowlist exists
OpenAI Responses web_search filters.allowed_domains (100 max) filters.blocked_domains (100 max) Yes

Anthropic, verbatim: "Provide allowed_domains or blocked_domains, not both. If a request includes both, the API returns a 400 error." Also watch for error code request_too_large, which the docs say fires "typically because of a long domain filter list" - your allowlist has an undocumented size ceiling.

This bites when your blocklist is dynamic (a growing table of banned domains) and your allowlist is static (a curated set of trusted outlets). On Anthropic you must pick one and enforce the other yourself after the fact. OpenAI's own example passes both inside one filters object.

The OpenAI path has its own trap: "domain filtering is only available in the Responses API with the web_search tool" - not Chat Completions search models, not web_search_preview. litellm has a Responses-to-Chat-Completions bridge that derives web_search_options from a Responses web_search tool (litellm/responses/litellm_completion_transformation/transformation.py, around lines 170-208 and 1264-1322). That derivation produces the Chat Completions shape, which cannot carry filters. So a request that looks like it has an allowlist can quietly lose it in translation. Verify the outgoing body, not the call site.

Brave News is the one option with a real publisher field

If you actually need a display name rather than a domain, GET|POST https://api.search.brave.com/res/v1/news/search carries one:

{"results": [{
  "title": "...", "url": "...", "description": "...",
  "age": "2 hours ago", "page_age": "2026-01-15T14:30:00",
  "meta_url": {"netloc": "news.example.com", "hostname": "..."},
  "profile": {"name": "Example Outlet", "long_name": "..."}
}]}

profile.name is the publisher. Note it is profile.name, not a top-level source field - several third-party write-ups get this wrong. page_age is an ISO datetime rather than Anthropic's freeform string. There is no include_domains parameter, but inline Goggles give you an allowlist with no registration step: $discard\n$site=reuters.com\n$site=apnews.com, blocklist form $discard,site=example.com, up to three goggles per request. Third-party reporting holds that Brave backs Anthropic's web_search, which would mean querying Brave directly buys you metadata and controls over the same index rather than a different one; Anthropic has never confirmed a backend in its docs.

The takeaway for pipeline design

Separate two problems that look like one. Naming is "the label next to the link reads like a domain" and is fixed by a domain-to-display-name map or by a provider field like profile.name; it never requires changing search provider. Precision is "the cited page should not have been cited at all" and is only fixed at search time with an allowlist, which is exactly the control that varies most across vendors. Conflating them sends you on a provider migration that cannot fix the symptom you actually see.

One dead end worth naming: the Bing Web Search API is not an escape hatch. It was retired on 2025-08-11 with instances decommissioned, and the Azure AI Agents "Grounding with Bing Search" replacement is not a standalone search API.

No signals yet