If you use the Vercel AI SDK with Gemini File Search or a RAG corpus, `generateText` gave you citations in `result.sources` and `streamText` gave you an empty array. Same model, same prompt, same grounding data. The citations were still in the raw provider metadata, so nothing threw and nothing logged. They just never reached the UI.

I hit this, traced it to one `if` in `@ai-sdk/google`, and sent the fix upstream. It was merged on 10 November 2026 and shipped in **`@ai-sdk/google` 4.0.118** on 11 November. This post walks through the bug, the fix, the part the maintainer changed in review and why their version is better, and what to do if you are on an older release. The issue is [vercel/ai#24723](https://github.com/vercel/ai/issues/24723) and the PR is [vercel/ai#24724](https://github.com/vercel/ai/pull/24724).

> **Tip:** Short version: run `npm i @ai-sdk/google@^4.0.118`. After that, `streamText` emits File Search and RAG document sources the same way `generateText` does.

## The symptom

Here is the setup. A route streams a Gemini answer grounded on a File Search store and forwards sources to the client, the way the SDK's own examples do it:

`app/api/chat/route.ts`

```ts
import { google } from '@ai-sdk/google';
import {
  convertToModelMessages,
  createUIMessageStreamResponse,
  streamText,
  toUIMessageStream,
  type UIMessage,
} from 'ai';

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: google('gemini-4-argon'),
    tools: {
      file_search: google.tools.fileSearch({
        fileSearchStoreNames: ['fileSearchStores/roof-reports'],
      }),
    },
    messages: await convertToModelMessages(messages),
  });

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({ stream: result.stream, sendSources: true }),
  });
}
```

The answer text streamed fine and clearly came from the documents. But no `source-document` parts arrived on the client, and `await result.sources` on the server was `[]`. Swapping `streamText` for `generateText` with the same inputs returned the citations. The provider docs say File Search responses include citations "via the normal `sources` field", so this was a bug, not a missing feature.

## Where the sources come from

Gemini returns grounding as `groundingMetadata.groundingChunks`. Each chunk has one of a few shapes: `web` for Google Search, `image`, `maps`, or `retrievedContext` for RAG and File Search. A File Search chunk has a `fileSearchStore` and no `uri`. A Vertex RAG chunk usually has a `gs://` URI:

`groundingMetadata (one stream chunk)`

```json
{
  "groundingChunks": [
    {
      "retrievedContext": {
        "fileSearchStore": "fileSearchStores/roof-reports",
        "title": "roof-report.pdf",
        "text": "Total roof area: 2,450 square feet."
      }
    },
    {
      "retrievedContext": {
        "uri": "gs://bucket/reports/annual.pdf",
        "title": "Annual report",
        "text": "Revenue grew."
      }
    }
  ]
}
```

Inside the provider, `extractSources()` turns those chunks into the SDK's source type. `web`, `image`, `maps` and `retrievedContext` with an `http(s)` URI become `sourceType: 'url'`. A `retrievedContext` with a `gs://` path or a `fileSearchStore` becomes `sourceType: 'document'`, with a `title`, a `filename` and a `mediaType`. That function was correct. The problem was what the two call paths did with its output.

## The bug: one filter meant for something else

![Diagram: grounding chunks go through extractSources. doGenerate keeps url and document sources. doStream keeps only url sources, so the gs:// and File Search documents are dropped.](https://mahmadmanzoor.com/shots/post-oss-ai-sdk/raster/bug.webp)

*Same chunks, two code paths. Only the streaming path filtered by source type.*

`doGenerate` pushed every extracted source into the result. `doStream` is different because Gemini can repeat the same grounding metadata on several chunks of one response, so it needs to avoid emitting duplicates. The duplicate check looked like this:

`packages/google/src/google-language-model.ts (before)`

```ts
const emittedSourceUrls = new Set<string>();

// ...inside the stream transform, for each chunk:
const sources = extractSources({
  groundingMetadata: candidate.groundingMetadata,
  generateId,
});
if (sources != null) {
  for (const source of sources) {
    if (
      source.sourceType === 'url' &&
      !emittedSourceUrls.has(source.url)
    ) {
      emittedSourceUrls.add(source.url);
      controller.enqueue(source);
    }
  }
}
```

The intent is "skip a URL we already sent". Document sources have no `url` field, so the dedupe was written for URLs only, and the `sourceType === 'url'` guard quietly turned it into "send URLs only". Every `document` source fell through the `if` and was never enqueued. That is why `streamText` lost exactly the File Search and `gs://` citations while Google Search citations kept working, which made it easy to miss in testing: if you only try web grounding, streaming looks fine.

## Reproducing it without the live API

I wanted a reproduction that used the public `streamText` and `generateText` APIs, not just a unit test on the model class. My Gemini project had run out of prepaid credits at the time (the API returned HTTP 402 `RESOURCE_EXHAUSTED`), so I served a Gemini-shaped response from a local HTTP server and pointed the provider at it with `baseURL`. The script imports the packages built from the checkout by absolute path, so no registry version can sneak in:

`repro.mjs`

```js
// Run with: AI_REPO=/path/to/ai node repro.mjs
import http from 'node:http';
import { pathToFileURL } from 'node:url';

const repo = process.env.AI_REPO;
const { streamText, generateText } = await import(
  pathToFileURL(`${repo}/packages/ai/dist/index.js`).href
);
const { createGoogle } = await import(
  pathToFileURL(`${repo}/packages/google/dist/index.js`).href
);

const candidate = {
  content: { parts: [{ text: 'The roof area is 2,450 sq ft.' }], role: 'model' },
  finishReason: 'STOP',
  index: 0,
  groundingMetadata: {
    groundingChunks: [
      { retrievedContext: { fileSearchStore: 'fileSearchStores/roof-reports', title: 'roof-report.pdf', text: 'Total roof area: 2,450 square feet.' } },
      { retrievedContext: { uri: 'gs://bucket/reports/annual.pdf', title: 'Annual report', text: 'Revenue grew.' } },
    ],
  },
};
const usageMetadata = { promptTokenCount: 10, candidatesTokenCount: 8, totalTokenCount: 18 };

const server = http.createServer((req, res) => {
  if (req.url.includes(':streamGenerateContent')) {
    res.writeHead(200, { 'content-type': 'text/event-stream' });
    res.end(`data: ${JSON.stringify({ candidates: [candidate], usageMetadata })}\n\n`);
  } else {
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end(JSON.stringify({ candidates: [candidate], usageMetadata }));
  }
});
await new Promise((r) => server.listen(0, r));

const google = createGoogle({
  apiKey: 'test',
  baseURL: `http://localhost:${server.address().port}/v1beta`,
});
const model = google('gemini-2.5-flash');
const prompt = 'What is the roof area?';

const g = await generateText({ model, prompt });
const s = streamText({ model, prompt });
await s.consumeStream();

const fmt = (srcs) => srcs.map((x) => `${x.sourceType}:${x.title}`).join(', ') || '(none)';
console.log('generateText sources:', fmt(g.sources));
console.log('streamText sources:  ', fmt(await s.sources));
server.close();
```

On `main` at the time (commit `6d21e8e`, `@ai-sdk/google` 4.0.113):

`output, before`

```
generateText sources: document:roof-report.pdf, document:Annual report
streamText sources:   (none)
```

I said plainly in the PR that this proves the SDK's handling end to end but not Google's live streaming payload, and offered to add a live run once credits were back. Reviewers can judge a limit you state. They can't judge one you hide.

## My fix, and the key I got wrong

The fix is to dedupe every source type instead of filtering to one. My first version kept a set of keys and built the key per type: the `url` for URL sources, and `title` plus `filename` for document sources. I added two `doStream` tests, one asserting that File Search and `gs://` chunks come out as document sources, and one asserting that the same document repeated on a later chunk is emitted once. Both failed on `main` and passed with the change. A patch changeset for `@ai-sdk/google` went in with it.

That key has a hole. `filename` for a `gs://` source is the last path segment, so `gs://bucket/2025/annual.pdf` and `gs://bucket/2026/annual.pdf` both become `annual.pdf`. If both are titled "Annual report", my key treats two different documents as one and drops the second. For File Search the filename is the store's last segment, so two stores ending in `/invoices` collide the same way.

## What the maintainer changed in review

The maintainer who reviewed it pushed a commit titled `deduplicate` that moved the key from "what the source looks like" to "what the source is". The check moved into `extractSources()` itself through an optional `emittedSourceKeys` set, and each branch now passes its own identity key:

![Diagram: URL-like chunks are keyed by ['url', uri], RAG chunks with a path by ['document', uri], File Search chunks by ['file-search', store, title]. All keys go into one emittedSourceKeys set.](https://mahmadmanzoor.com/shots/post-oss-ai-sdk/raster/fix.webp)

*Each source is keyed by its identity, and one set is shared across all stream chunks.*

`packages/google/src/google-language-model.ts (merged, trimmed)`

```diff
-    const emittedSourceUrls = new Set<string>();
+    const emittedSourceKeys = new Set<string>();
 ...
             const sources = extractSources({
               groundingMetadata: candidate.groundingMetadata,
               generateId,
+              emittedSourceKeys,
             });
             if (sources != null) {
               for (const source of sources) {
-                if (
-                  source.sourceType === 'url' &&
-                  !emittedSourceUrls.has(source.url)
-                ) {
-                  emittedSourceUrls.add(source.url);
-                  controller.enqueue(source);
-                }
+                controller.enqueue(source);
               }
             }
```

`extractSources() after the merge (excerpt)`

```ts
const addSource = (source: LanguageModelV4Source, key: string) => {
  if (emittedSourceKeys?.has(key)) {
    return;
  }
  emittedSourceKeys?.add(key);
  sources.push(source);
};

// web, image, maps, http(s) retrievedContext
addSource({ type: 'source', sourceType: 'url', id: generateId(), url: uri, title },
  JSON.stringify(['url', uri]));

// retrievedContext with gs:// or another path
addSource({ type: 'source', sourceType: 'document', id: generateId(), mediaType, title, filename },
  JSON.stringify(['document', uri]));

// File Search: fileSearchStore, no uri
addSource({ type: 'source', sourceType: 'document', id: generateId(),
  mediaType: 'application/octet-stream', title, filename: fileSearchStore.split('/').pop() },
  JSON.stringify(['file-search', fileSearchStore, title]));
```

A few details in that design are worth copying into your own code:

- **The key is the identity, not the display fields.** A RAG source is keyed by its full URI, so two `annual.pdf` files in different folders stay separate, and the same URI with a different title is still one source.
- **The type is part of the key.** `['url', x]` and `['document', x]` can never collide, even if the same string shows up in both.
- **`JSON.stringify` of an array instead of string concatenation.** Joining with a separator breaks when a value contains that separator. Serialising a tuple can't be ambiguous that way.
- **The set is optional.** `doGenerate` calls `extractSources()` without it and behaves exactly as before, so the non-streaming path didn't change at all.

The maintainer also added `it.each` cases for those collisions: different RAG paths with the same title and filename (expect 2), the same RAG URI with different titles (expect 1), and different File Search stores with the same basename. Then approved and merged. The changeset text in the release reads "Deduplicate by document URI or File Search store and title so distinct documents with matching titles and filenames are preserved."

> **Note:** The lesson I took from the review: when you dedupe, ask what makes two records the same thing, not what makes them look the same. Title and filename are what a user sees. The URI is what the record is.

## From issue to release

![Timeline: issue 24723 and PR 24724 opened on 7 November 2026, review and merge on 10 November, released in @ai-sdk/google 4.0.118 on 11 November.](https://mahmadmanzoor.com/shots/post-oss-ai-sdk/raster/timeline.webp)

*Four days from bug report to a published package.*

The issue went in first, with the exact cause, the offending lines and a minimal grounding payload, so the maintainers could confirm the bug without running anything. The PR linked to it with `Fixes #24723`, listed what changed, what the tests cover, and the verification limit. The whole change was 289 lines added and 54 removed across the source, the test file and a changeset, and most of the added lines are tests.

## What to do in your app

Check which version you have:

`terminal`

```bash
npm ls @ai-sdk/google
# upgrade to the release with the fix (or anything newer)
npm i @ai-sdk/google@^4.0.118
```

After upgrading, streamed File Search and RAG citations show up wherever you already read sources. On the server they are in `await result.sources`. On the client, with `sendSources: true`, they arrive as `source-document` parts on the message, next to the `source-url` parts from web grounding:

`components/Sources.tsx`

```tsx
import type { UIMessage } from 'ai';

export function Sources({ message }: { message: UIMessage }) {
  const docs = message.parts.filter((p) => p.type === 'source-document');
  if (docs.length === 0) return null;
  return (
    <ul>
      {docs.map((d) => (
        <li key={d.sourceId}>
          {d.title}
          {d.filename ? ` (${d.filename})` : ''}
        </li>
      ))}
    </ul>
  );
}
```

If you can't upgrade yet, the raw grounding is still there. `streamText` exposes the final step's provider metadata once the stream finishes, so you can read the chunks yourself:

`workaround for versions before 4.0.118`

```ts
const result = streamText({ model, tools, messages });

// after the stream completes
const { providerMetadata: meta } = await result.finalStep;
const chunks = meta?.google?.groundingMetadata?.groundingChunks ?? [];
const docs = chunks
  .map((c: any) => c.retrievedContext)
  .filter(Boolean)
  .map((rc: any) => ({ title: rc.title, uri: rc.uri, store: rc.fileSearchStore }));
```

That only gives you the metadata at the end, not as source parts during the stream, which is the main reason to upgrade instead.

## If you are thinking about your first fix

This was my first merged contribution to a project of this size, so a few notes for anyone about to try:

- **Lead with the cause, not the symptom.** "Sources are empty" sends a maintainer debugging. "`doStream` only enqueues `sourceType === 'url'`, here are the lines" lets them agree in a minute.
- **Write the failing test first and say it fails on `main`.** It turns your claim into something the CI checks.
- **State what you could not verify.** I couldn't hit the live API. Saying so cost nothing and nobody objected.
- **Expect your fix to change.** The maintainer's dedupe key was better than mine. Merged with their edit is still a merged fix, and the edit taught me more than an instant approval would have.

If you build RAG features on Gemini with the AI SDK, upgrade and check your citations panel with a streamed answer. It probably had nothing in it before.

---

*Originally published on [my site](https://mahmadmanzoor.com/blog/vercel-ai-sdk-gemini-file-search-sources-streaming). More posts and projects at [mahmadmanzoor.com](https://mahmadmanzoor.com).*
