|
|
@@ -1,13 +1,6 @@
|
|
|
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 { createGoogleGenerativeAI, type GoogleGenerativeAIProvider } from "@ai-sdk/google"
|
|
|
+import { streamText, generateText, ToolSet } from "ai"
|
|
|
|
|
|
import {
|
|
|
type ModelInfo,
|
|
|
@@ -16,59 +9,42 @@ import {
|
|
|
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 {
|
|
|
+ convertToAiSdkMessages,
|
|
|
+ convertToolsForAiSdk,
|
|
|
+ processAiSdkStreamPart,
|
|
|
+ mapToolChoice,
|
|
|
+} from "../transform/ai-sdk"
|
|
|
import { t } from "i18next"
|
|
|
-import type { ApiStream, GroundingSource } from "../transform/stream"
|
|
|
+import type { ApiStream, ApiStreamUsageChunk, 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
|
|
|
-}
|
|
|
+import { DEFAULT_HEADERS } from "./constants"
|
|
|
|
|
|
export class GeminiHandler extends BaseProvider implements SingleCompletionHandler {
|
|
|
protected options: ApiHandlerOptions
|
|
|
-
|
|
|
- private client: GoogleGenAI
|
|
|
- private lastThoughtSignature?: string
|
|
|
- private lastResponseId?: string
|
|
|
+ protected provider: GoogleGenerativeAIProvider
|
|
|
private readonly providerName = "Gemini"
|
|
|
|
|
|
- constructor({ isVertex, ...options }: GeminiHandlerOptions) {
|
|
|
+ constructor(options: ApiHandlerOptions) {
|
|
|
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 })
|
|
|
+ // Create the Google Generative AI provider using AI SDK
|
|
|
+ // For Vertex AI, we still use this provider but with different authentication
|
|
|
+ // (Vertex authentication happens separately)
|
|
|
+ this.provider = createGoogleGenerativeAI({
|
|
|
+ apiKey: this.options.geminiApiKey ?? "not-provided",
|
|
|
+ baseURL: this.options.googleGeminiBaseUrl,
|
|
|
+ headers: DEFAULT_HEADERS,
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
async *createMessage(
|
|
|
@@ -76,10 +52,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|
|
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
|
|
|
+ const { id: modelId, info, reasoning: thinkingConfig, maxTokens } = this.getModel()
|
|
|
|
|
|
// 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
|
|
|
@@ -90,19 +63,23 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|
|
? (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)
|
|
|
+ // 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
|
|
|
|
|
|
// 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.
|
|
|
+ // Anthropic.MessageParam values and will cause failures.
|
|
|
type ReasoningMetaLike = { type?: string }
|
|
|
|
|
|
- const geminiMessages = messages.filter((message): message is Anthropic.Messages.MessageParam => {
|
|
|
+ const filteredMessages = messages.filter((message): message is Anthropic.Messages.MessageParam => {
|
|
|
const meta = message as ReasoningMetaLike
|
|
|
if (meta.type === "reasoning") {
|
|
|
return false
|
|
|
@@ -110,232 +87,80 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|
|
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,
|
|
|
- })),
|
|
|
- },
|
|
|
- ]
|
|
|
+ // Convert messages to AI SDK format
|
|
|
+ const aiSdkMessages = convertToAiSdkMessages(filteredMessages)
|
|
|
|
|
|
- // 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
|
|
|
+ // Convert tools to OpenAI format first, then to AI SDK format
|
|
|
+ let openAiTools = this.convertToolsForOpenAI(metadata?.tools)
|
|
|
|
|
|
- const config: GenerateContentConfig = {
|
|
|
- systemInstruction,
|
|
|
- httpOptions: this.options.googleGeminiBaseUrl ? { baseUrl: this.options.googleGeminiBaseUrl } : undefined,
|
|
|
- thinkingConfig,
|
|
|
- maxOutputTokens,
|
|
|
- temperature: temperatureConfig,
|
|
|
- ...(tools.length > 0 ? { tools } : {}),
|
|
|
+ // Filter tools based on allowedFunctionNames for mode-restricted tool access
|
|
|
+ if (metadata?.allowedFunctionNames && metadata.allowedFunctionNames.length > 0 && openAiTools) {
|
|
|
+ const allowedSet = new Set(metadata.allowedFunctionNames)
|
|
|
+ openAiTools = openAiTools.filter((tool) => tool.type === "function" && allowedSet.has(tool.function.name))
|
|
|
}
|
|
|
|
|
|
- // 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
|
|
|
- }
|
|
|
+ const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined
|
|
|
|
|
|
- config.toolConfig = {
|
|
|
- functionCallingConfig: {
|
|
|
- mode,
|
|
|
- ...(allowedFunctionNames ? { allowedFunctionNames } : {}),
|
|
|
- },
|
|
|
- }
|
|
|
- }
|
|
|
+ // Build tool choice - use 'required' when allowedFunctionNames restricts available tools
|
|
|
+ const toolChoice =
|
|
|
+ metadata?.allowedFunctionNames && metadata.allowedFunctionNames.length > 0
|
|
|
+ ? "required"
|
|
|
+ : mapToolChoice(metadata?.tool_choice)
|
|
|
|
|
|
- const params: GenerateContentParameters = { model, contents, config }
|
|
|
+ // Build the request options
|
|
|
+ const requestOptions: Parameters<typeof streamText>[0] = {
|
|
|
+ model: this.provider(modelId),
|
|
|
+ system: systemInstruction,
|
|
|
+ messages: aiSdkMessages,
|
|
|
+ temperature: temperatureConfig,
|
|
|
+ maxOutputTokens,
|
|
|
+ tools: aiSdkTools,
|
|
|
+ toolChoice,
|
|
|
+ // Add thinking/reasoning configuration if present
|
|
|
+ // Cast to any to bypass strict JSONObject typing - the AI SDK accepts the correct runtime values
|
|
|
+ ...(thinkingConfig && {
|
|
|
+ providerOptions: { google: { thinkingConfig } } as any,
|
|
|
+ }),
|
|
|
+ }
|
|
|
|
|
|
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]
|
|
|
+ // Use streamText for streaming responses
|
|
|
+ const result = streamText(requestOptions)
|
|
|
|
|
|
- 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
|
|
|
+ // Process the full stream to get all events including reasoning
|
|
|
+ for await (const part of result.fullStream) {
|
|
|
+ for (const chunk of processAiSdkStreamPart(part)) {
|
|
|
+ yield chunk
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- if (finalResponse?.responseId) {
|
|
|
- // Capture responseId so Task.addToApiConversationHistory can store it
|
|
|
- // alongside the assistant message in api_history.json.
|
|
|
- this.lastResponseId = finalResponse.responseId
|
|
|
- }
|
|
|
+ // Extract grounding sources from providerMetadata if available
|
|
|
+ const providerMetadata = await result.providerMetadata
|
|
|
+ const groundingMetadata = providerMetadata?.google as
|
|
|
+ | {
|
|
|
+ groundingMetadata?: {
|
|
|
+ groundingChunks?: Array<{
|
|
|
+ web?: { uri?: string; title?: string }
|
|
|
+ }>
|
|
|
+ }
|
|
|
+ }
|
|
|
+ | undefined
|
|
|
|
|
|
- if (pendingGroundingMetadata) {
|
|
|
- const sources = this.extractGroundingSources(pendingGroundingMetadata)
|
|
|
+ if (groundingMetadata?.groundingMetadata) {
|
|
|
+ const sources = this.extractGroundingSources(groundingMetadata.groundingMetadata)
|
|
|
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,
|
|
|
- }),
|
|
|
- }
|
|
|
+ // Yield usage metrics at the end
|
|
|
+ const usage = await result.usage
|
|
|
+ if (usage) {
|
|
|
+ yield this.processUsageMetrics(usage, info, providerMetadata)
|
|
|
}
|
|
|
} catch (error) {
|
|
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
|
- const apiError = new ApiProviderError(errorMessage, this.providerName, model, "createMessage")
|
|
|
+ const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage")
|
|
|
TelemetryService.instance.captureException(apiError)
|
|
|
|
|
|
if (error instanceof Error) {
|
|
|
@@ -366,7 +191,47 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|
|
return { id: id.endsWith(":thinking") ? id.replace(":thinking", "") : id, info, ...params }
|
|
|
}
|
|
|
|
|
|
- private extractGroundingSources(groundingMetadata?: GroundingMetadata): GroundingSource[] {
|
|
|
+ /**
|
|
|
+ * Process usage metrics from the AI SDK response.
|
|
|
+ */
|
|
|
+ protected processUsageMetrics(
|
|
|
+ usage: {
|
|
|
+ inputTokens?: number
|
|
|
+ outputTokens?: number
|
|
|
+ details?: {
|
|
|
+ cachedInputTokens?: number
|
|
|
+ reasoningTokens?: number
|
|
|
+ }
|
|
|
+ },
|
|
|
+ info: ModelInfo,
|
|
|
+ providerMetadata?: Record<string, unknown>,
|
|
|
+ ): ApiStreamUsageChunk {
|
|
|
+ const inputTokens = usage.inputTokens || 0
|
|
|
+ const outputTokens = usage.outputTokens || 0
|
|
|
+ const cacheReadTokens = usage.details?.cachedInputTokens
|
|
|
+ const reasoningTokens = usage.details?.reasoningTokens
|
|
|
+
|
|
|
+ return {
|
|
|
+ type: "usage",
|
|
|
+ inputTokens,
|
|
|
+ outputTokens,
|
|
|
+ cacheReadTokens,
|
|
|
+ reasoningTokens,
|
|
|
+ totalCost: this.calculateCost({
|
|
|
+ info,
|
|
|
+ inputTokens,
|
|
|
+ outputTokens,
|
|
|
+ cacheReadTokens,
|
|
|
+ reasoningTokens,
|
|
|
+ }),
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private extractGroundingSources(groundingMetadata?: {
|
|
|
+ groundingChunks?: Array<{
|
|
|
+ web?: { uri?: string; title?: string }
|
|
|
+ }>
|
|
|
+ }): GroundingSource[] {
|
|
|
const chunks = groundingMetadata?.groundingChunks
|
|
|
|
|
|
if (!chunks) {
|
|
|
@@ -389,7 +254,11 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|
|
.filter((source): source is GroundingSource => source !== null)
|
|
|
}
|
|
|
|
|
|
- private extractCitationsOnly(groundingMetadata?: GroundingMetadata): string | null {
|
|
|
+ private extractCitationsOnly(groundingMetadata?: {
|
|
|
+ groundingChunks?: Array<{
|
|
|
+ web?: { uri?: string; title?: string }
|
|
|
+ }>
|
|
|
+ }): string | null {
|
|
|
const sources = this.extractGroundingSources(groundingMetadata)
|
|
|
|
|
|
if (sources.length === 0) {
|
|
|
@@ -401,15 +270,21 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|
|
}
|
|
|
|
|
|
async completePrompt(prompt: string): Promise<string> {
|
|
|
- const { id: model, info } = this.getModel()
|
|
|
+ const { id: modelId, info } = this.getModel()
|
|
|
|
|
|
try {
|
|
|
- const tools: GenerateContentConfig["tools"] = []
|
|
|
+ // Build tools for grounding - cast to any to bypass strict typing
|
|
|
+ // Google provider tools have a different shape than standard ToolSet
|
|
|
+ const tools: Record<string, any> = {}
|
|
|
+
|
|
|
+ // Add URL context tool if enabled
|
|
|
if (this.options.enableUrlContext) {
|
|
|
- tools.push({ urlContext: {} })
|
|
|
+ tools.url_context = this.provider.tools.urlContext({})
|
|
|
}
|
|
|
+
|
|
|
+ // Add Google Search grounding tool if enabled
|
|
|
if (this.options.enableGrounding) {
|
|
|
- tools.push({ googleSearch: {} })
|
|
|
+ tools.google_search = this.provider.tools.googleSearch({})
|
|
|
}
|
|
|
|
|
|
const supportsTemperature = info.supportsTemperature !== false
|
|
|
@@ -417,27 +292,29 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|
|
? (this.options.modelTemperature ?? info.defaultTemperature ?? 1)
|
|
|
: info.defaultTemperature
|
|
|
|
|
|
- const promptConfig: GenerateContentConfig = {
|
|
|
- httpOptions: this.options.googleGeminiBaseUrl
|
|
|
- ? { baseUrl: this.options.googleGeminiBaseUrl }
|
|
|
- : undefined,
|
|
|
+ const result = await generateText({
|
|
|
+ model: this.provider(modelId),
|
|
|
+ prompt,
|
|
|
temperature: temperatureConfig,
|
|
|
- ...(tools.length > 0 ? { tools } : {}),
|
|
|
- }
|
|
|
-
|
|
|
- const request = {
|
|
|
- model,
|
|
|
- contents: [{ role: "user", parts: [{ text: prompt }] }],
|
|
|
- config: promptConfig,
|
|
|
- }
|
|
|
-
|
|
|
- const result = await this.client.models.generateContent(request)
|
|
|
+ ...(Object.keys(tools).length > 0 && { tools: tools as ToolSet }),
|
|
|
+ })
|
|
|
|
|
|
let text = result.text ?? ""
|
|
|
|
|
|
- const candidate = result.candidates?.[0]
|
|
|
- if (candidate?.groundingMetadata) {
|
|
|
- const citations = this.extractCitationsOnly(candidate.groundingMetadata)
|
|
|
+ // Extract grounding citations from providerMetadata if available
|
|
|
+ const providerMetadata = result.providerMetadata
|
|
|
+ const groundingMetadata = providerMetadata?.google as
|
|
|
+ | {
|
|
|
+ groundingMetadata?: {
|
|
|
+ groundingChunks?: Array<{
|
|
|
+ web?: { uri?: string; title?: string }
|
|
|
+ }>
|
|
|
+ }
|
|
|
+ }
|
|
|
+ | undefined
|
|
|
+
|
|
|
+ if (groundingMetadata?.groundingMetadata) {
|
|
|
+ const citations = this.extractCitationsOnly(groundingMetadata.groundingMetadata)
|
|
|
if (citations) {
|
|
|
text += `\n\n${t("common:errors.gemini.sources")} ${citations}`
|
|
|
}
|
|
|
@@ -446,7 +323,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|
|
return text
|
|
|
} catch (error) {
|
|
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
|
- const apiError = new ApiProviderError(errorMessage, this.providerName, model, "completePrompt")
|
|
|
+ const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt")
|
|
|
TelemetryService.instance.captureException(apiError)
|
|
|
|
|
|
if (error instanceof Error) {
|
|
|
@@ -457,14 +334,6 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- public getThoughtSignature(): string | undefined {
|
|
|
- return this.lastThoughtSignature
|
|
|
- }
|
|
|
-
|
|
|
- public getResponseId(): string | undefined {
|
|
|
- return this.lastResponseId
|
|
|
- }
|
|
|
-
|
|
|
public calculateCost({
|
|
|
info,
|
|
|
inputTokens,
|