perplexed

Gemini joins the provider lineup — and brings the per-claim citation that Claude's dynamic-filter lost

Perplexed now ships a fourth AI provider: Gemini, with Google Search grounding. Gemini's `groundingSupports[]` carries per-segment attribution (text span → source URL) that survives intact, where Claude's `web_search_20260209` dynamic-filter sandbox drops it on the floor. Same modal UX as Ask Claude, same Citations footer shape so cite-wide hex substitution stays provider-agnostic. Two Gemini-specific quirks surfaced when we dissected a real response with curl: chunk URLs are short-lived `vertexaisearch.cloud.google.com` redirects that rot in ~30 days (the plugin resolves them to durable source URLs before writing), and the spec's `searchEntryPoint.renderedContent` is 5KB of inline-styled HTML that Obsidian's Markdown renderer can't display (replaced with a Markdown list of the queries, each linked to google.com/search).

Ask Gemini — fourth provider, first one with surviving per-claim citations

Why care?

If you write research notes in Obsidian, you now have a fourth way to ground a draft in live web sources without leaving the editor:

  • Ask Gemini — runs Google Search behind the scenes and streams the answer into your note at the cursor, just like Ask Claude and Ask Perplexity.

  • Per-claim citations actually work. Gemini's response carries a mapping from each cited sentence to the specific URL it came from, which means the ### Citations section at the bottom of your note is not a flat URL dump — it's a quote-per-source list you can verify by reading.

  • You opt in per-question. A toggle in the Ask Gemini modal turns Google Search grounding on or off without touching settings, so you can compare grounded vs. ungrounded answers on the same prompt.

What's new?

Five settings, all behind one Settings → Perplexed → Gemini (Google) section:

Gemini (Google)
├── Gemini API key                                      (paste from Google AI Studio)
├── Default Gemini model                                (2.5 pro / 2.5 flash / 2.0 flash)
├── Enable Google search grounding by default           (on)
├── Include Google searches list in notes               (on — Markdown bullets, not HTML chip)
└── Resolve citation urls (durable, slower)             (on — fixes the 30-day URL rot)
  • Ask Gemini command opens a modal matching the Ask Claude UX — full-width question textarea, model dropdown with taglines, three behavior toggles (grounding / suggestions chip / streaming), Cmd-Enter to submit.

  • Check Gemini service status command reports whether the service initialized and whether an API key is configured.

  • Citations footer mirrors Claude's exactly — [N]: [Title](url). > cited_text — so cite-wide's hex-substitution pass treats Gemini output the same as everything else.

How it works

The Gemini response shape carries two layers of provenance:

LayerFieldWhat it gives you
Page-levelgroundingChunks[]URL + title per page Gemini consulted
Segment-levelgroundingSupports[]Text span (segment.text) → indices into groundingChunks[]

The plugin walks both layers and merges them by URL: page-level entries come in as URL-and-title fallbacks; segment-level entries enrich them with the actual sentence Gemini grounded against. First quote per source wins, on the principle that the earliest segment is closest to the source's lede.

Compare to the Claude path documented in context-v/issues/Getting-Claude-to-Respond-With-Research.md:

"web_search_20260209's dynamic-filtering pass post-processes search results in a code-execution sandbox, and per-claim web_search_result_location citations don't survive that round-trip — text blocks come back with citations: null."

Gemini doesn't have that round-trip. groundingSupports[] ships in the final response unmodified, so the ### Citations section you get is actually attached to the prose above it rather than guessed from string matches.

What the response actually contains (dissected with curl, 2026-05-19)

Two things bit us once we ran a real gemini-2.5-flash request with google_search enabled:

SurfaceDocumented asActuallyWhat the plugin does
chunk.web.uri"source URL"vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIY… — a Google-controlled redirect that expires ~30 days after the responseresolveCitationUrls() does a parallel fetch(uri, { redirect: 'manual' }) per cited source (3s timeout each), reads the Location header, falls back to the redirect URL on failure
chunk.web.title"title"The source DOMAIN ("nobelprize.org"), not the page titleRendered as the link text in [domain.com](resolved-url) — acceptable; a future pass could grab <title> during URL resolution
searchEntryPoint.renderedContentGoogle's required Search Suggestions chip~5KB of <style>-tag-prefixed inline HTML that Obsidian's Markdown renderer strips, leaving orphan <div> chipsReplaced with a Markdown ### Google Searches section listing webSearchQueries[], each query linked to google.com/search?q=…. Same end-user behavior (re-run the suggested search), Markdown-native rendering
support.segment.startIndex/endIndex"text span offsets"UTF-8 BYTE offsets, not char offsetsCurrently unused (we use segment.text directly) but flagged with a code comment for anyone wiring inline [N] markers later

The grounded-attribution layer (groundingSupports[]groundingChunkIndices) is exactly what we hoped — segment.text is the verbatim quote, and it points cleanly back to chunk indices. That's the half of this that Just Works.

Architecture

Mirrors the Claude provider exactly, no shared base class:

src/services/geminiService.ts       (new — raw fetch, SSE streaming, groundingMetadata parsing,
                                     Citations + Search Queries + Suggestions blocks)
src/modals/GeminiModal.ts           (new — Ask Claude shape, gemini-themed)
main.ts
├── PerplexedPluginSettings + DEFAULT_SETTINGS    (geminiApiKey, geminiDefaultModel,
│                                                  geminiEnableGrounding,
│                                                  geminiIncludeSearchSuggestions)
├── private geminiService            (lifecycle parallel to claudeService)
├── service init blocks              (onload + reinitializeServices)
├── registerGeminiCommands           (ask-gemini, gemini-service-status)
└── Settings tab — Gemini (Google) section with four rows

Zero new dependencies — uses activeWindow.fetch for SSE and Obsidian's request for non-streaming, same pattern as perplexityService.ts. The @google/genai SDK was evaluated and skipped: it pulls in Node-flavored streaming primitives and JSON-schema validators we don't need, and the REST shape is small enough that a hand-rolled parser is more honest about what's on the wire.

Build status

pnpm run build (eslint + tsc + esbuild production) green. ObsidianReviewBot's obsidianmd/ui/sentence-case rule was the only friction — it recognizes "Google" as a proper noun (capitalize) and "URLs" as an acronym (lowercase to "urls"), each of which collides with our usual sentence-case house style. Resolved by following the rule's autosuggestion verbatim; the lint passes and the rule's reasoning is internally consistent even where it looks odd to a human reader.

What's next

  • Page-title-not-domain in citations. Right now [nobelprize.org](https://www.nobelprize.org/prizes/physics/2024/summary/) shows the domain as link text because that's what chunk.web.title returns. We're already paying for a fetch() round-trip per cited source during URL resolution; one tweak to also parse <title> out of the response body would give us real page titles for free. Future pass.

  • Strict Google grounding-chip compliance. Google's grounding ToS technically says display searchEntryPoint.renderedContent verbatim. We're displaying the Markdown-equivalent (webSearchQueries[] linked to google.com/search) because the rendered chip doesn't survive Obsidian's Markdown renderer. If Google enforces this strictly we may need a separate "open the chip in a popup" affordance — for now we believe the spirit-of-the-terms is satisfied.

  • Gemini-specific preamble pipeline. Right now Ask Gemini runs the user's question straight through — no loadPreamble + expandIncludes pass like the directory-template service does. Adding it is a one-line refactor (call the same helpers from directoryTemplateService.ts before building the request body) and would let partials/ and preambles/ apply to ad-hoc Gemini queries too.

  • Hook Gemini into directory templates. A template that declares provider: gemini in its cft fence should be routable through the Gemini service the same way the existing templates route through Perplexity. Same template engine, different transport.

  • The editorial-stance partial extraction (deferred from the previous shipping pass — the anti-incumbent stance is still duplicated across concept-profile.md and vocabulary-profile.md).

Files touched

src/services/geminiService.ts                       (new, 320 lines)
src/modals/GeminiModal.ts                           (new, 140 lines)
main.ts                                             (~80 lines net — types, defaults, init,
                                                     commands, settings section)