mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-10 22:41:14 +00:00
- 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
329 lines
9.9 KiB
TypeScript
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
|
|
}
|
|
}
|