← Back to blog

· 9 min read

Fixing the Vercel AI SDK Bug That Dropped Gemini File Search Citations in streamText

streamText in @ai-sdk/google returned no sources for Gemini File Search and RAG while generateText did. The one-line cause, the fix I got merged into the Vercel AI SDK, the dedupe key the maintainer improved, and how to upgrade.

  • vercel-ai-sdk
  • open-source
  • gemini
  • rag
  • file-search
  • typescript
  • streaming

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 and the PR is vercel/ai#24724.

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.tsTypeScript
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.
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)TypeScript
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.mjsJavaScript
// 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, beforeText
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.
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)TypeScript
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."

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.
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:

terminalBash
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.tsxTSX
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.118TypeScript
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.