Roo-Code/src/api/providers/gemini.ts
Roo Code a9174a8bb4 fix: integrate Gemini grounding sources into assistant message
- Modified streaming logic to append grounding sources to the last text chunk instead of yielding as separate message
- Added tracking of content yielding to ensure sources only appear when content exists
- Added comprehensive test coverage for grounding functionality including edge cases
- Fixes issue where grounding sources appeared as separate message bubbles

Fixes #6372
2025-07-29 17:19:38 +00:00

329 lines
9.9 KiB
TypeScript

import type { Anthropic } from "@anthropic-ai/sdk"
import {
GoogleGenAI,
type GenerateContentResponseUsageMetadata,
type GenerateContentParameters,
type GenerateContentConfig,
type GroundingMetadata,
} from "@google/genai"
import type { JWTInput } from "google-auth-library"
import { type ModelInfo, type GeminiModelId, geminiDefaultModelId, geminiModels } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../shared/api"
import { safeJsonParse } from "../../shared/safeJsonParse"
import { convertAnthropicContentToGemini, convertAnthropicMessageToGemini } from "../transform/gemini-format"
import { t } from "i18next"
import type { ApiStream } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
import { BaseProvider } from "./base-provider"
type GeminiHandlerOptions = ApiHandlerOptions & {
isVertex?: boolean
}
export class GeminiHandler extends BaseProvider implements SingleCompletionHandler {
protected options: ApiHandlerOptions
private client: GoogleGenAI
constructor({ isVertex, ...options }: GeminiHandlerOptions) {
super()
this.options = options
const project = this.options.vertexProjectId ?? "not-provided"
const location = this.options.vertexRegion ?? "not-provided"
const apiKey = this.options.geminiApiKey ?? "not-provided"
this.client = this.options.vertexJsonCredentials
? new GoogleGenAI({
vertexai: true,
project,
location,
googleAuthOptions: {
credentials: safeJsonParse<JWTInput>(this.options.vertexJsonCredentials, undefined),
},
})
: this.options.vertexKeyFile
? new GoogleGenAI({
vertexai: true,
project,
location,
googleAuthOptions: { keyFile: this.options.vertexKeyFile },
})
: isVertex
? new GoogleGenAI({ vertexai: true, project, location })
: new GoogleGenAI({ apiKey })
}
async *createMessage(
systemInstruction: string,
messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
const { id: model, info, reasoning: thinkingConfig, maxTokens } = this.getModel()
const contents = messages.map(convertAnthropicMessageToGemini)
const tools: GenerateContentConfig["tools"] = []
if (this.options.enableUrlContext) {
tools.push({ urlContext: {} })
}
if (this.options.enableGrounding) {
tools.push({ googleSearch: {} })
}
const config: GenerateContentConfig = {
systemInstruction,
httpOptions: this.options.googleGeminiBaseUrl ? { baseUrl: this.options.googleGeminiBaseUrl } : undefined,
thinkingConfig,
maxOutputTokens: this.options.modelMaxTokens ?? maxTokens ?? undefined,
temperature: this.options.modelTemperature ?? 0,
...(tools.length > 0 ? { tools } : {}),
}
const params: GenerateContentParameters = { model, contents, config }
try {
const result = await this.client.models.generateContentStream(params)
let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined
let pendingGroundingMetadata: GroundingMetadata | undefined
let lastTextChunk: string | null = null
let hasYieldedContent = false
for await (const chunk of result) {
// Process candidates and their parts to separate thoughts from content
if (chunk.candidates && chunk.candidates.length > 0) {
const candidate = chunk.candidates[0]
if (candidate.groundingMetadata) {
pendingGroundingMetadata = candidate.groundingMetadata
}
if (candidate.content && candidate.content.parts) {
for (const part of candidate.content.parts) {
if (part.thought) {
// This is a thinking/reasoning part
if (part.text) {
yield { type: "reasoning", text: part.text }
}
} else {
// This is regular content
if (part.text) {
lastTextChunk = part.text
hasYieldedContent = true
yield { type: "text", text: part.text }
}
}
}
}
}
// Fallback to the original text property if no candidates structure
else if (chunk.text) {
lastTextChunk = chunk.text
hasYieldedContent = true
yield { type: "text", text: chunk.text }
}
if (chunk.usageMetadata) {
lastUsageMetadata = chunk.usageMetadata
}
}
// If we have grounding metadata and content was yielded, append sources to the last text chunk
if (pendingGroundingMetadata && hasYieldedContent) {
const citations = this.extractCitationsOnly(pendingGroundingMetadata)
if (citations) {
const sourcesText = `\n\n${t("common:errors.gemini.sources")} ${citations}`
yield { type: "text", text: sourcesText }
}
}
if (lastUsageMetadata) {
const inputTokens = lastUsageMetadata.promptTokenCount ?? 0
const outputTokens = lastUsageMetadata.candidatesTokenCount ?? 0
const cacheReadTokens = lastUsageMetadata.cachedContentTokenCount
const reasoningTokens = lastUsageMetadata.thoughtsTokenCount
yield {
type: "usage",
inputTokens,
outputTokens,
cacheReadTokens,
reasoningTokens,
totalCost: this.calculateCost({ info, inputTokens, outputTokens, cacheReadTokens }),
}
}
} catch (error) {
if (error instanceof Error) {
throw new Error(t("common:errors.gemini.generate_stream", { error: error.message }))
}
throw error
}
}
override getModel() {
const modelId = this.options.apiModelId
let id = modelId && modelId in geminiModels ? (modelId as GeminiModelId) : geminiDefaultModelId
let info: ModelInfo = geminiModels[id]
const params = getModelParams({ format: "gemini", modelId: id, model: info, settings: this.options })
// The `:thinking` suffix indicates that the model is a "Hybrid"
// reasoning model and that reasoning is required to be enabled.
// The actual model ID honored by Gemini's API does not have this
// suffix.
return { id: id.endsWith(":thinking") ? id.replace(":thinking", "") : id, info, ...params }
}
private extractCitationsOnly(groundingMetadata?: GroundingMetadata): string | null {
const chunks = groundingMetadata?.groundingChunks
if (!chunks) {
return null
}
const citationLinks = chunks
.map((chunk, i) => {
const uri = chunk.web?.uri
if (uri) {
return `[${i + 1}](${uri})`
}
return null
})
.filter((link): link is string => link !== null)
if (citationLinks.length > 0) {
return citationLinks.join(", ")
}
return null
}
async completePrompt(prompt: string): Promise<string> {
try {
const { id: model } = this.getModel()
const tools: GenerateContentConfig["tools"] = []
if (this.options.enableUrlContext) {
tools.push({ urlContext: {} })
}
if (this.options.enableGrounding) {
tools.push({ googleSearch: {} })
}
const promptConfig: GenerateContentConfig = {
httpOptions: this.options.googleGeminiBaseUrl
? { baseUrl: this.options.googleGeminiBaseUrl }
: undefined,
temperature: this.options.modelTemperature ?? 0,
...(tools.length > 0 ? { tools } : {}),
}
const result = await this.client.models.generateContent({
model,
contents: [{ role: "user", parts: [{ text: prompt }] }],
config: promptConfig,
})
let text = result.text ?? ""
const candidate = result.candidates?.[0]
if (candidate?.groundingMetadata) {
const citations = this.extractCitationsOnly(candidate.groundingMetadata)
if (citations) {
text += `\n\n${t("common:errors.gemini.sources")} ${citations}`
}
}
return text
} catch (error) {
if (error instanceof Error) {
throw new Error(t("common:errors.gemini.generate_complete_prompt", { error: error.message }))
}
throw error
}
}
override async countTokens(content: Array<Anthropic.Messages.ContentBlockParam>): Promise<number> {
try {
const { id: model } = this.getModel()
const response = await this.client.models.countTokens({
model,
contents: convertAnthropicContentToGemini(content),
})
if (response.totalTokens === undefined) {
console.warn("Gemini token counting returned undefined, using fallback")
return super.countTokens(content)
}
return response.totalTokens
} catch (error) {
console.warn("Gemini token counting failed, using fallback", error)
return super.countTokens(content)
}
}
public calculateCost({
info,
inputTokens,
outputTokens,
cacheReadTokens = 0,
}: {
info: ModelInfo
inputTokens: number
outputTokens: number
cacheReadTokens?: number
}) {
if (!info.inputPrice || !info.outputPrice || !info.cacheReadsPrice) {
return undefined
}
let inputPrice = info.inputPrice
let outputPrice = info.outputPrice
let cacheReadsPrice = info.cacheReadsPrice
// If there's tiered pricing then adjust the input and output token prices
// based on the input tokens used.
if (info.tiers) {
const tier = info.tiers.find((tier) => inputTokens <= tier.contextWindow)
if (tier) {
inputPrice = tier.inputPrice ?? inputPrice
outputPrice = tier.outputPrice ?? outputPrice
cacheReadsPrice = tier.cacheReadsPrice ?? cacheReadsPrice
}
}
// Subtract the cached input tokens from the total input tokens.
const uncachedInputTokens = inputTokens - cacheReadTokens
let cacheReadCost = cacheReadTokens > 0 ? cacheReadsPrice * (cacheReadTokens / 1_000_000) : 0
const inputTokensCost = inputPrice * (uncachedInputTokens / 1_000_000)
const outputTokensCost = outputPrice * (outputTokens / 1_000_000)
const totalCost = inputTokensCost + outputTokensCost + cacheReadCost
const trace: Record<string, { price: number; tokens: number; cost: number }> = {
input: { price: inputPrice, tokens: uncachedInputTokens, cost: inputTokensCost },
output: { price: outputPrice, tokens: outputTokens, cost: outputTokensCost },
}
if (cacheReadTokens > 0) {
trace.cacheRead = { price: cacheReadsPrice, tokens: cacheReadTokens, cost: cacheReadCost }
}
// console.log(`[GeminiHandler] calculateCost -> ${totalCost}`, trace)
return totalCost
}
}