Roo-Code/src/api/providers/gemini.ts
roomote[bot] 0d5b932d2e
feat: disable apply_diff and enable edit tool for Vertex and Gemini providers (#11619)
* feat: disable apply_diff and enable edit tool for Vertex and Gemini providers

* feat: disable apply_diff and enable edit tool for Anthropic provider

* feat: disable apply_diff and enable edit tool for Anthropic Vertex provider

* fix: remove out-of-scope Anthropic/Anthropic-Vertex changes

The PR scope is Gemini and Vertex providers only. Reverting the
Anthropic and Anthropic-Vertex tool preference changes that were
not part of the stated scope.

---------

Co-authored-by: Roo Code <roomote@roocode.com>
2026-02-19 23:02:44 -07:00

529 lines
17 KiB
TypeScript

import type { Anthropic } from "@anthropic-ai/sdk"
import {
GoogleGenAI,
type GenerateContentResponseUsageMetadata,
type GenerateContentParameters,
type GenerateContentConfig,
type GroundingMetadata,
FunctionCallingConfigMode,
} from "@google/genai"
import type { JWTInput } from "google-auth-library"
import {
type ModelInfo,
type GeminiModelId,
geminiDefaultModelId,
geminiModels,
ApiProviderError,
} from "@roo-code/types"
import { safeJsonParse } from "@roo-code/core"
import { TelemetryService } from "@roo-code/telemetry"
import type { ApiHandlerOptions } from "../../shared/api"
import { convertAnthropicMessageToGemini } from "../transform/gemini-format"
import { t } from "i18next"
import type { ApiStream, GroundingSource } 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
private lastThoughtSignature?: string
private lastResponseId?: string
private readonly providerName = "Gemini"
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()
// Reset per-request metadata that we persist into apiConversationHistory.
this.lastThoughtSignature = undefined
this.lastResponseId = undefined
// For hybrid/budget reasoning models (e.g. Gemini 2.5 Pro), respect user-configured
// modelMaxTokens so the ThinkingBudget slider can control the cap. For effort-only or
// standard models (like gemini-3-pro-preview), ignore any stale modelMaxTokens and
// default to the model's computed maxTokens from getModelMaxOutputTokens.
const isHybridReasoningModel = info.supportsReasoningBudget || info.requiredReasoningBudget
const maxOutputTokens = isHybridReasoningModel
? (this.options.modelMaxTokens ?? maxTokens ?? undefined)
: (maxTokens ?? undefined)
// Gemini 3 validates thought signatures for tool/function calling steps.
// We must round-trip the signature when tools are in use, even if the user chose
// a minimal thinking level (or thinkingConfig is otherwise absent).
const includeThoughtSignatures = Boolean(thinkingConfig) || Boolean(metadata?.tools?.length)
// The message list can include provider-specific meta entries such as
// `{ type: "reasoning", ... }` that are intended only for providers like
// openai-native. Gemini should never see those; they are not valid
// Anthropic.MessageParam values and will cause failures (e.g. missing
// `content` for the converter). Filter them out here.
type ReasoningMetaLike = { type?: string }
const geminiMessages = messages.filter((message): message is Anthropic.Messages.MessageParam => {
const meta = message as ReasoningMetaLike
if (meta.type === "reasoning") {
return false
}
return true
})
// Build a map of tool IDs to names from previous messages
// This is needed because Anthropic's tool_result blocks only contain the ID,
// but Gemini requires the name in functionResponse
const toolIdToName = new Map<string, string>()
for (const message of messages) {
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === "tool_use") {
toolIdToName.set(block.id, block.name)
}
}
}
}
const contents = geminiMessages
.map((message) => convertAnthropicMessageToGemini(message, { includeThoughtSignatures, toolIdToName }))
.flat()
// Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS).
// Google built-in tools (Grounding, URL Context) are mutually exclusive
// with function declarations in the Gemini API, so we always use
// function declarations when tools are provided.
const tools: GenerateContentConfig["tools"] = [
{
functionDeclarations: (metadata?.tools ?? []).map((tool) => ({
name: (tool as any).function.name,
description: (tool as any).function.description,
parametersJsonSchema: (tool as any).function.parameters,
})),
},
]
// Determine temperature respecting model capabilities and defaults:
// - If supportsTemperature is explicitly false, ignore user overrides
// and pin to the model's defaultTemperature (or omit if undefined).
// - Otherwise, allow the user setting to override, falling back to model default,
// then to 1 for Gemini provider default.
const supportsTemperature = info.supportsTemperature !== false
const temperatureConfig: number | undefined = supportsTemperature
? (this.options.modelTemperature ?? info.defaultTemperature ?? 1)
: info.defaultTemperature
const config: GenerateContentConfig = {
systemInstruction,
httpOptions: this.options.googleGeminiBaseUrl ? { baseUrl: this.options.googleGeminiBaseUrl } : undefined,
thinkingConfig,
maxOutputTokens,
temperature: temperatureConfig,
...(tools.length > 0 ? { tools } : {}),
}
// Handle allowedFunctionNames for mode-restricted tool access.
// When provided, all tool definitions are passed to the model (so it can reference
// historical tool calls in conversation), but only the specified tools can be invoked.
// This takes precedence over tool_choice to ensure mode restrictions are honored.
if (metadata?.allowedFunctionNames && metadata.allowedFunctionNames.length > 0) {
config.toolConfig = {
functionCallingConfig: {
// Use ANY mode to allow calling any of the allowed functions
mode: FunctionCallingConfigMode.ANY,
allowedFunctionNames: metadata.allowedFunctionNames,
},
}
} else if (metadata?.tool_choice) {
const choice = metadata.tool_choice
let mode: FunctionCallingConfigMode
let allowedFunctionNames: string[] | undefined
if (choice === "auto") {
mode = FunctionCallingConfigMode.AUTO
} else if (choice === "none") {
mode = FunctionCallingConfigMode.NONE
} else if (choice === "required") {
// "required" means the model must call at least one tool; Gemini uses ANY for this.
mode = FunctionCallingConfigMode.ANY
} else if (typeof choice === "object" && "function" in choice && choice.type === "function") {
mode = FunctionCallingConfigMode.ANY
allowedFunctionNames = [choice.function.name]
} else {
// Fall back to AUTO for unknown values to avoid unintentionally broadening tool access.
mode = FunctionCallingConfigMode.AUTO
}
config.toolConfig = {
functionCallingConfig: {
mode,
...(allowedFunctionNames ? { allowedFunctionNames } : {}),
},
}
}
const params: GenerateContentParameters = { model, contents, config }
try {
const result = await this.client.models.generateContentStream(params)
let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined
let pendingGroundingMetadata: GroundingMetadata | undefined
let finalResponse: { responseId?: string } | undefined
let finishReason: string | undefined
let toolCallCounter = 0
let hasContent = false
let hasReasoning = false
for await (const chunk of result) {
// Track the final structured response (per SDK pattern: candidate.finishReason)
if (chunk.candidates && chunk.candidates[0]?.finishReason) {
finalResponse = chunk as { responseId?: string }
finishReason = chunk.candidates[0].finishReason
}
// 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 as Array<{
thought?: boolean
text?: string
thoughtSignature?: string
functionCall?: { name: string; args: Record<string, unknown> }
}>) {
// Capture thought signatures so they can be persisted into API history.
const thoughtSignature = part.thoughtSignature
// Persist thought signatures so they can be round-tripped in the next step.
// Gemini 3 requires this during tool calling; other Gemini thinking models
// benefit from it for continuity.
if (includeThoughtSignatures && thoughtSignature) {
this.lastThoughtSignature = thoughtSignature
}
if (part.thought) {
// This is a thinking/reasoning part
if (part.text) {
hasReasoning = true
yield { type: "reasoning", text: part.text }
}
} else if (part.functionCall) {
hasContent = true
// Gemini sends complete function calls in a single chunk
// Emit as partial chunks for consistent handling with NativeToolCallParser
const callId = `${part.functionCall.name}-${toolCallCounter}`
const args = JSON.stringify(part.functionCall.args)
// Emit name first
yield {
type: "tool_call_partial",
index: toolCallCounter,
id: callId,
name: part.functionCall.name,
arguments: undefined,
}
// Then emit arguments
yield {
type: "tool_call_partial",
index: toolCallCounter,
id: callId,
name: undefined,
arguments: args,
}
toolCallCounter++
} else {
// This is regular content
if (part.text) {
hasContent = true
yield { type: "text", text: part.text }
}
}
}
}
}
// Fallback to the original text property if no candidates structure
else if (chunk.text) {
hasContent = true
yield { type: "text", text: chunk.text }
}
if (chunk.usageMetadata) {
lastUsageMetadata = chunk.usageMetadata
}
}
if (finalResponse?.responseId) {
// Capture responseId so Task.addToApiConversationHistory can store it
// alongside the assistant message in api_history.json.
this.lastResponseId = finalResponse.responseId
}
if (pendingGroundingMetadata) {
const sources = this.extractGroundingSources(pendingGroundingMetadata)
if (sources.length > 0) {
yield { type: "grounding", sources }
}
}
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,
reasoningTokens,
}),
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
const apiError = new ApiProviderError(errorMessage, this.providerName, model, "createMessage")
TelemetryService.instance.captureException(apiError)
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,
defaultTemperature: info.defaultTemperature ?? 1,
})
// Gemini models perform better with the edit tool instead of apply_diff.
info = {
...info,
excludedTools: [...new Set([...(info.excludedTools || []), "apply_diff"])],
includedTools: [...new Set([...(info.includedTools || []), "edit"])],
}
// 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 extractGroundingSources(groundingMetadata?: GroundingMetadata): GroundingSource[] {
const chunks = groundingMetadata?.groundingChunks
if (!chunks) {
return []
}
return chunks
.map((chunk): GroundingSource | null => {
const uri = chunk.web?.uri
const title = chunk.web?.title || uri || "Unknown Source"
if (uri) {
return {
title,
url: uri,
}
}
return null
})
.filter((source): source is GroundingSource => source !== null)
}
private extractCitationsOnly(groundingMetadata?: GroundingMetadata): string | null {
const sources = this.extractGroundingSources(groundingMetadata)
if (sources.length === 0) {
return null
}
const citationLinks = sources.map((source, i) => `[${i + 1}](${source.url})`)
return citationLinks.join(", ")
}
async completePrompt(prompt: string): Promise<string> {
const { id: model, info } = this.getModel()
try {
const supportsTemperature = info.supportsTemperature !== false
const temperatureConfig: number | undefined = supportsTemperature
? (this.options.modelTemperature ?? info.defaultTemperature ?? 1)
: info.defaultTemperature
const promptConfig: GenerateContentConfig = {
httpOptions: this.options.googleGeminiBaseUrl
? { baseUrl: this.options.googleGeminiBaseUrl }
: undefined,
temperature: temperatureConfig,
}
const request = {
model,
contents: [{ role: "user", parts: [{ text: prompt }] }],
config: promptConfig,
}
const result = await this.client.models.generateContent(request)
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) {
const errorMessage = error instanceof Error ? error.message : String(error)
const apiError = new ApiProviderError(errorMessage, this.providerName, model, "completePrompt")
TelemetryService.instance.captureException(apiError)
if (error instanceof Error) {
throw new Error(t("common:errors.gemini.generate_complete_prompt", { error: error.message }))
}
throw error
}
}
public getThoughtSignature(): string | undefined {
return this.lastThoughtSignature
}
public getResponseId(): string | undefined {
return this.lastResponseId
}
public calculateCost({
info,
inputTokens,
outputTokens,
cacheReadTokens = 0,
reasoningTokens = 0,
}: {
info: ModelInfo
inputTokens: number
outputTokens: number
cacheReadTokens?: number
reasoningTokens?: number
}) {
// For models with tiered pricing, prices might only be defined in tiers
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
}
}
// Check if we have the required prices after considering tiers
if (!inputPrice || !outputPrice) {
return undefined
}
// cacheReadsPrice is optional - if not defined, treat as 0
if (!cacheReadsPrice) {
cacheReadsPrice = 0
}
// Subtract the cached input tokens from the total input tokens.
const uncachedInputTokens = inputTokens - cacheReadTokens
// Bill both completion and reasoning ("thoughts") tokens as output.
const billedOutputTokens = outputTokens + reasoningTokens
let cacheReadCost = cacheReadTokens > 0 ? cacheReadsPrice * (cacheReadTokens / 1_000_000) : 0
const inputTokensCost = inputPrice * (uncachedInputTokens / 1_000_000)
const outputTokensCost = outputPrice * (billedOutputTokens / 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: billedOutputTokens, cost: outputTokensCost },
}
if (cacheReadTokens > 0) {
trace.cacheRead = { price: cacheReadsPrice, tokens: cacheReadTokens, cost: cacheReadCost }
}
return totalCost
}
}