Roo-Code/src/api/providers/vertex.ts

437 lines
13 KiB
TypeScript

import type { Anthropic } from "@anthropic-ai/sdk"
import { createVertex, type GoogleVertexProvider } from "@ai-sdk/google-vertex"
import { streamText, generateText, ToolSet, ModelMessage } from "ai"
import {
type ModelInfo,
type VertexModelId,
vertexDefaultModelId,
vertexModels,
ApiProviderError,
} from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import type { ApiHandlerOptions } from "../../shared/api"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,
handleAiSdkError,
yieldResponseMessage,
} from "../transform/ai-sdk"
import { applyPromptCacheToMessages, mergeProviderOptions } from "../transform/prompt-cache"
import { t } from "i18next"
import type { ApiStream, ApiStreamUsageChunk, GroundingSource } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
import { normalizeProviderUsage } from "./utils/normalize-provider-usage"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
import { BaseProvider } from "./base-provider"
import { DEFAULT_HEADERS } from "./constants"
import type { RooMessage } from "../../core/task-persistence/rooMessage"
/**
* Vertex AI provider using the dedicated @ai-sdk/google-vertex package.
* Provides native support for Google's Vertex AI with proper authentication.
*/
export class VertexHandler extends BaseProvider implements SingleCompletionHandler {
protected options: ApiHandlerOptions
protected provider: GoogleVertexProvider
private readonly providerName = "Vertex"
constructor(options: ApiHandlerOptions) {
super()
this.options = options
// Build googleAuthOptions based on provided credentials
let googleAuthOptions: { credentials?: object; keyFile?: string } | undefined
if (options.vertexJsonCredentials) {
try {
googleAuthOptions = { credentials: JSON.parse(options.vertexJsonCredentials) }
} catch {
// If JSON parsing fails, ignore and try other auth methods
}
} else if (options.vertexKeyFile) {
googleAuthOptions = { keyFile: options.vertexKeyFile }
}
// Create the Vertex AI provider using AI SDK
this.provider = createVertex({
project: options.vertexProjectId,
location: options.vertexRegion,
googleAuthOptions,
headers: DEFAULT_HEADERS,
})
}
async *createMessage(
systemInstruction: string,
messages: RooMessage[],
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
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
// 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)
// 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. Vertex should never see those; they are not valid
// Anthropic.MessageParam values and will cause failures.
type ReasoningMetaLike = { type?: string }
const filteredMessages = messages.filter((message) => {
const meta = message as ReasoningMetaLike
if (meta.type === "reasoning") {
return false
}
return true
})
// Convert messages to AI SDK format
const aiSdkMessages = filteredMessages as ModelMessage[]
const promptCache = applyPromptCacheToMessages({
adapter: "ai-sdk",
overrideKey: "vertex",
messages: aiSdkMessages,
modelInfo: {
supportsPromptCache: info.supportsPromptCache,
promptCacheRetention: info.promptCacheRetention,
},
settings: this.options,
})
// Convert tools to OpenAI format first, then to AI SDK format
let openAiTools = this.convertToolsForOpenAI(metadata?.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))
}
const aiSdkTools = convertToolsForAiSdk(openAiTools, {
functionToolProviderOptions: promptCache.toolProviderOptions,
}) as ToolSet | undefined
// Build tool choice - use 'required' when allowedFunctionNames restricts available tools
const toolChoice =
metadata?.allowedFunctionNames && metadata.allowedFunctionNames.length > 0
? "required"
: mapToolChoice(metadata?.tool_choice)
// Build the request options
const providerOptions = mergeProviderOptions(
thinkingConfig ? ({ vertex: { thinkingConfig } } as Record<string, unknown>) : undefined,
promptCache.providerOptionsPatch,
)
const requestOptions: Parameters<typeof streamText>[0] = {
model: this.provider(modelId),
system: promptCache.systemProviderOptions
? ({
role: "system",
content: systemInstruction,
providerOptions: promptCache.systemProviderOptions,
} as any)
: systemInstruction,
messages: aiSdkMessages,
temperature: temperatureConfig,
maxOutputTokens,
tools: aiSdkTools,
toolChoice,
...(providerOptions ? ({ providerOptions } as Record<string, unknown>) : {}),
}
try {
// Use streamText for streaming responses
const result = streamText(requestOptions)
// Process the full stream to get all events including reasoning
let lastStreamError: string | undefined
for await (const part of result.fullStream) {
for (const chunk of processAiSdkStreamPart(part)) {
if (chunk.type === "error") {
lastStreamError = chunk.message
}
yield chunk
}
}
// Extract grounding sources and usage from providerMetadata
try {
const providerMetadata = await result.providerMetadata
const groundingMetadata = (providerMetadata?.vertex ?? providerMetadata?.google) as
| {
groundingMetadata?: {
groundingChunks?: Array<{
web?: { uri?: string; title?: string }
}>
}
}
| undefined
if (groundingMetadata?.groundingMetadata) {
const sources = this.extractGroundingSources(groundingMetadata.groundingMetadata)
if (sources.length > 0) {
yield { type: "grounding", sources }
}
}
// Yield usage metrics at the end
const usage = await result.usage
if (usage) {
yield this.processUsageMetrics(usage, info, providerMetadata)
}
} catch (usageError) {
if (lastStreamError) {
throw new Error(lastStreamError)
}
throw usageError
}
yield* yieldResponseMessage(result)
} catch (error) {
throw handleAiSdkError(error, this.providerName, {
onError: (msg) => {
TelemetryService.instance.captureException(
new ApiProviderError(msg, this.providerName, modelId, "createMessage"),
)
},
formatMessage: (msg) => t("common:errors.gemini.generate_stream", { error: msg }),
})
}
}
override getModel() {
const modelId = this.options.apiModelId
let id = modelId && modelId in vertexModels ? (modelId as VertexModelId) : vertexDefaultModelId
let info: ModelInfo = vertexModels[id]
const params = getModelParams({
format: "gemini",
modelId: id,
model: info,
settings: this.options,
defaultTemperature: info.defaultTemperature ?? 1,
})
// 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 }
}
/**
* Process usage metrics from the AI SDK response.
*/
protected processUsageMetrics(
usage: {
inputTokens?: number
outputTokens?: number
cachedInputTokens?: number
reasoningTokens?: number
inputTokenDetails?: {
cacheReadTokens?: number
}
outputTokenDetails?: {
reasoningTokens?: number
}
details?: {
cachedInputTokens?: number
reasoningTokens?: number
}
},
info: ModelInfo,
providerMetadata?: Record<string, unknown>,
): ApiStreamUsageChunk {
const normalized = normalizeProviderUsage({
provider: "vertex",
apiProtocol: "openai",
usage: usage as any,
providerMetadata,
modelInfo: info,
})
return {
...normalized.chunk,
totalCost: this.calculateCost({
info,
inputTokens: normalized.canonical.inputTokensTotal,
outputTokens: normalized.canonical.outputTokens,
cacheReadTokens: normalized.canonical.cacheReadTokens,
reasoningTokens: normalized.canonical.reasoningTokens ?? 0,
}),
}
}
private extractGroundingSources(groundingMetadata?: {
groundingChunks?: Array<{
web?: { uri?: string; title?: string }
}>
}): 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?: {
groundingChunks?: Array<{
web?: { uri?: string; title?: string }
}>
}): 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: modelId, info } = this.getModel()
try {
const supportsTemperature = info.supportsTemperature !== false
const temperatureConfig: number | undefined = supportsTemperature
? (this.options.modelTemperature ?? info.defaultTemperature ?? 1)
: info.defaultTemperature
const result = await generateText({
model: this.provider(modelId),
prompt,
temperature: temperatureConfig,
})
let text = result.text ?? ""
// Extract grounding citations from providerMetadata if available
const providerMetadata = result.providerMetadata
const groundingMetadata = (providerMetadata?.vertex ?? 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}`
}
}
return text
} catch (error) {
throw handleAiSdkError(error, this.providerName, {
onError: (msg) => {
TelemetryService.instance.captureException(
new ApiProviderError(msg, this.providerName, modelId, "completePrompt"),
)
},
formatMessage: (msg) => t("common:errors.gemini.generate_complete_prompt", { error: msg }),
})
}
}
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
return totalCost
}
override isAiSdkProvider(): boolean {
return true
}
}