feat: add custom model support for Vertex AI provider

- Add custom model input field to Vertex provider settings UI
- Update provider settings schema to include vertexCustomModelId field
- Modify useSelectedModel hook to prioritize custom models for Vertex
- Update AnthropicVertexHandler to handle custom model names with thinking suffix support
- Add comprehensive tests for custom model functionality
- Add i18n translations for custom model UI elements

Fixes #5751: Users can now input custom Vertex AI model IDs in the correct @ format (e.g., claude-sonnet-4@20250514) to avoid 404 errors
This commit is contained in:
Roo Code 2025-07-15 17:44:07 +00:00
parent d0452d0d5c
commit 56c872d8e4
8 changed files with 354 additions and 445 deletions

View file

@ -116,6 +116,7 @@ const vertexSchema = apiModelIdProviderModelSchema.extend({
vertexJsonCredentials: z.string().optional(),
vertexProjectId: z.string().optional(),
vertexRegion: z.string().optional(),
vertexCustomModelId: z.string().optional(),
})
const openAiSchema = baseProviderSettingsSchema.extend({

View file

@ -809,4 +809,124 @@ describe("VertexHandler", () => {
)
})
})
describe("custom model handling", () => {
it("should use custom model when vertexCustomModelId is provided", () => {
handler = new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexCustomModelId: "claude-sonnet-4@20250514",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
})
const modelInfo = handler.getModel()
expect(modelInfo.id).toBe("claude-sonnet-4@20250514")
// Should use default model info as fallback
expect(modelInfo.info).toBeDefined()
expect(modelInfo.info.maxTokens).toBe(8192)
expect(modelInfo.info.contextWindow).toBe(200_000)
})
it("should trim whitespace from custom model ID", () => {
handler = new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexCustomModelId: " claude-sonnet-4@20250514 ",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
})
const modelInfo = handler.getModel()
expect(modelInfo.id).toBe("claude-sonnet-4@20250514")
})
it("should handle custom model with thinking suffix", () => {
handler = new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexCustomModelId: "claude-sonnet-4@20250514:thinking",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
modelMaxTokens: 16384,
modelMaxThinkingTokens: 4096,
})
const modelInfo = handler.getModel()
expect(modelInfo.id).toBe("claude-sonnet-4@20250514")
// For custom models with thinking suffix, reasoning parameters should be set
expect(modelInfo.reasoningBudget).toBe(4096)
expect(modelInfo.temperature).toBe(1.0)
})
it("should fall back to predefined model when custom model is empty", () => {
handler = new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexCustomModelId: "",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
})
const modelInfo = handler.getModel()
expect(modelInfo.id).toBe("claude-3-5-sonnet-v2@20241022")
})
it("should fall back to predefined model when custom model is only whitespace", () => {
handler = new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexCustomModelId: " ",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
})
const modelInfo = handler.getModel()
expect(modelInfo.id).toBe("claude-3-5-sonnet-v2@20241022")
})
it("should fall back to default model when custom model is not provided", () => {
handler = new AnthropicVertexHandler({
vertexProjectId: "test-project",
vertexRegion: "us-central1",
})
const modelInfo = handler.getModel()
expect(modelInfo.id).toBe("claude-sonnet-4@20250514") // default model
})
it("should use custom model in API calls", async () => {
handler = new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexCustomModelId: "claude-sonnet-4@20250514",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
})
const mockCreate = vitest.fn().mockImplementation(async (options) => {
return {
async *[Symbol.asyncIterator]() {
yield {
type: "message_start",
message: {
usage: {
input_tokens: 10,
output_tokens: 5,
},
},
}
},
}
})
;(handler["client"].messages as any).create = mockCreate
const stream = handler.createMessage("You are a helpful assistant", [{ role: "user", content: "Hello" }])
// Consume the stream
for await (const _chunk of stream) {
// Just consume the stream
}
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: "claude-sonnet-4@20250514",
}),
)
})
})
})

View file

@ -163,6 +163,45 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple
}
getModel() {
// Check if a custom model is specified
const customModelId = this.options.vertexCustomModelId
if (customModelId && customModelId.trim()) {
// For custom models, use default model info as fallback
const defaultInfo: ModelInfo = vertexModels[vertexDefaultModelId]
const trimmedId = customModelId.trim()
// Check if custom model has thinking suffix
const hasThinkingSuffix = trimmedId.endsWith(":thinking")
const actualModelId = hasThinkingSuffix ? trimmedId.replace(":thinking", "") : trimmedId
// For thinking models, create a model info that supports reasoning
let modelInfo: ModelInfo = defaultInfo
if (hasThinkingSuffix) {
modelInfo = {
...defaultInfo,
supportsReasoningBudget: true,
requiredReasoningBudget: true,
maxThinkingTokens: defaultInfo.maxThinkingTokens || 8192,
}
}
// Use the full model ID (with :thinking suffix) for getModelParams to get proper reasoning parameters
const modelIdForParams = hasThinkingSuffix ? trimmedId : actualModelId
const params = getModelParams({
format: "anthropic",
modelId: modelIdForParams,
model: modelInfo,
settings: this.options,
})
return {
id: actualModelId,
info: modelInfo,
...params,
}
}
// Use predefined models
const modelId = this.options.apiModelId
let id = modelId && modelId in vertexModels ? (modelId as VertexModelId) : vertexDefaultModelId
const info: ModelInfo = vertexModels[id]

View file

@ -91,6 +91,16 @@ export const Vertex = ({ apiConfiguration, setApiConfigurationField }: VertexPro
</SelectContent>
</Select>
</div>
<VSCodeTextField
value={apiConfiguration?.vertexCustomModelId || ""}
onInput={handleInputChange("vertexCustomModelId")}
placeholder="claude-sonnet-4@20250514"
className="w-full">
<label className="block font-medium mb-1">{t("settings:providers.customModel")}</label>
</VSCodeTextField>
<div className="text-sm text-vscode-descriptionForeground">
{t("settings:providers.vertex.customModelDescription")}
</div>
</>
)
}

View file

@ -1,444 +0,0 @@
// npx vitest src/components/ui/hooks/__tests__/useSelectedModel.spec.ts
import React from "react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { renderHook } from "@testing-library/react"
import type { Mock } from "vitest"
import { ProviderSettings, ModelInfo } from "@roo-code/types"
import { useSelectedModel } from "../useSelectedModel"
import { useRouterModels } from "../useRouterModels"
import { useOpenRouterModelProviders } from "../useOpenRouterModelProviders"
vi.mock("../useRouterModels")
vi.mock("../useOpenRouterModelProviders")
const mockUseRouterModels = useRouterModels as Mock<typeof useRouterModels>
const mockUseOpenRouterModelProviders = useOpenRouterModelProviders as Mock<typeof useOpenRouterModelProviders>
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
describe("useSelectedModel", () => {
describe("OpenRouter provider merging", () => {
it("should merge base model info with specific provider info when both exist", () => {
const baseModelInfo: ModelInfo = {
maxTokens: 4096,
contextWindow: 8192,
supportsImages: false,
supportsPromptCache: false,
}
const specificProviderInfo: ModelInfo = {
maxTokens: 8192, // Different value that should override
contextWindow: 16384, // Different value that should override
supportsImages: true, // Different value that should override
supportsPromptCache: true, // Different value that should override
inputPrice: 0.001,
outputPrice: 0.002,
description: "Provider-specific description",
}
mockUseRouterModels.mockReturnValue({
data: {
openrouter: {
"test-model": baseModelInfo,
},
requesty: {},
glama: {},
unbound: {},
litellm: {},
},
isLoading: false,
isError: false,
} as any)
mockUseOpenRouterModelProviders.mockReturnValue({
data: {
"test-provider": specificProviderInfo,
},
isLoading: false,
isError: false,
} as any)
const apiConfiguration: ProviderSettings = {
apiProvider: "openrouter",
openRouterModelId: "test-model",
openRouterSpecificProvider: "test-provider",
}
const wrapper = createWrapper()
const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper })
expect(result.current.id).toBe("test-model")
expect(result.current.info).toEqual({
maxTokens: 8192, // From specific provider (overrides base)
contextWindow: 16384, // From specific provider (overrides base)
supportsImages: true, // From specific provider (overrides base)
supportsPromptCache: true, // From specific provider (overrides base)
inputPrice: 0.001,
outputPrice: 0.002,
description: "Provider-specific description",
})
})
it("should use only specific provider info when base model info is missing", () => {
const specificProviderInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 16384,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0.001,
outputPrice: 0.002,
description: "Provider-specific description",
}
mockUseRouterModels.mockReturnValue({
data: {
openrouter: {},
requesty: {},
glama: {},
unbound: {},
litellm: {},
},
isLoading: false,
isError: false,
} as any)
mockUseOpenRouterModelProviders.mockReturnValue({
data: {
"test-provider": specificProviderInfo,
},
isLoading: false,
isError: false,
} as any)
const apiConfiguration: ProviderSettings = {
apiProvider: "openrouter",
openRouterModelId: "test-model",
openRouterSpecificProvider: "test-provider",
}
const wrapper = createWrapper()
const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper })
expect(result.current.id).toBe("test-model")
expect(result.current.info).toEqual(specificProviderInfo)
})
it("should demonstrate the merging behavior validates the comment about missing fields", () => {
const baseModelInfo: ModelInfo = {
maxTokens: 4096,
contextWindow: 8192,
supportsImages: false,
supportsPromptCache: false,
supportsComputerUse: true,
cacheWritesPrice: 0.1,
cacheReadsPrice: 0.01,
}
const specificProviderInfo: Partial<ModelInfo> = {
inputPrice: 0.001,
outputPrice: 0.002,
description: "Provider-specific description",
maxTokens: 8192, // Override this one
supportsImages: true, // Override this one
}
mockUseRouterModels.mockReturnValue({
data: {
openrouter: {
"test-model": baseModelInfo,
},
requesty: {},
glama: {},
unbound: {},
litellm: {},
},
isLoading: false,
isError: false,
} as any)
mockUseOpenRouterModelProviders.mockReturnValue({
data: { "test-provider": specificProviderInfo as ModelInfo },
isLoading: false,
isError: false,
} as any)
const apiConfiguration: ProviderSettings = {
apiProvider: "openrouter",
openRouterModelId: "test-model",
openRouterSpecificProvider: "test-provider",
}
const wrapper = createWrapper()
const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper })
expect(result.current.id).toBe("test-model")
expect(result.current.info).toEqual({
// Fields from base model that provider doesn't have
contextWindow: 8192, // From base (provider doesn't override)
supportsPromptCache: false, // From base (provider doesn't override)
supportsComputerUse: true, // From base (provider doesn't have)
cacheWritesPrice: 0.1, // From base (provider doesn't have)
cacheReadsPrice: 0.01, // From base (provider doesn't have)
// Fields overridden by provider
maxTokens: 8192, // From provider (overrides base)
supportsImages: true, // From provider (overrides base)
// Fields only in provider
inputPrice: 0.001, // From provider (base doesn't have)
outputPrice: 0.002, // From provider (base doesn't have)
description: "Provider-specific description", // From provider (base doesn't have)
})
})
it("should use base model info when no specific provider is configured", () => {
const baseModelInfo: ModelInfo = {
maxTokens: 4096,
contextWindow: 8192,
supportsImages: false,
supportsPromptCache: false,
}
mockUseRouterModels.mockReturnValue({
data: {
openrouter: { "test-model": baseModelInfo },
requesty: {},
glama: {},
unbound: {},
litellm: {},
},
isLoading: false,
isError: false,
} as any)
mockUseOpenRouterModelProviders.mockReturnValue({
data: {},
isLoading: false,
isError: false,
} as any)
const apiConfiguration: ProviderSettings = {
apiProvider: "openrouter",
openRouterModelId: "test-model",
}
const wrapper = createWrapper()
const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper })
expect(result.current.id).toBe("test-model")
expect(result.current.info).toEqual(baseModelInfo)
})
it("should fall back to default when both base and specific provider info are missing", () => {
mockUseRouterModels.mockReturnValue({
data: {
openrouter: {
"anthropic/claude-sonnet-4": {
// Default model
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
},
},
requesty: {},
glama: {},
unbound: {},
litellm: {},
},
isLoading: false,
isError: false,
} as any)
mockUseOpenRouterModelProviders.mockReturnValue({
data: {},
isLoading: false,
isError: false,
} as any)
const apiConfiguration: ProviderSettings = {
apiProvider: "openrouter",
openRouterModelId: "non-existent-model",
openRouterSpecificProvider: "non-existent-provider",
}
const wrapper = createWrapper()
const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper })
expect(result.current.id).toBe("non-existent-model")
expect(result.current.info).toBeUndefined()
})
})
describe("loading and error states", () => {
it("should return loading state when router models are loading", () => {
mockUseRouterModels.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
} as any)
mockUseOpenRouterModelProviders.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
} as any)
const wrapper = createWrapper()
const { result } = renderHook(() => useSelectedModel(), { wrapper })
expect(result.current.isLoading).toBe(true)
})
it("should return loading state when open router model providers are loading", () => {
mockUseRouterModels.mockReturnValue({
data: { openrouter: {}, requesty: {}, glama: {}, unbound: {}, litellm: {} },
isLoading: false,
isError: false,
} as any)
mockUseOpenRouterModelProviders.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
} as any)
const wrapper = createWrapper()
const { result } = renderHook(() => useSelectedModel(), { wrapper })
expect(result.current.isLoading).toBe(true)
})
it("should return error state when either hook has an error", () => {
mockUseRouterModels.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
} as any)
mockUseOpenRouterModelProviders.mockReturnValue({
data: {},
isLoading: false,
isError: false,
} as any)
const wrapper = createWrapper()
const { result } = renderHook(() => useSelectedModel(), { wrapper })
expect(result.current.isError).toBe(true)
})
})
describe("default behavior", () => {
it("should return anthropic default when no configuration is provided", () => {
mockUseRouterModels.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
} as any)
mockUseOpenRouterModelProviders.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
} as any)
const wrapper = createWrapper()
const { result } = renderHook(() => useSelectedModel(), { wrapper })
expect(result.current.provider).toBe("anthropic")
expect(result.current.id).toBe("claude-sonnet-4-20250514")
expect(result.current.info).toBeUndefined()
})
})
describe("claude-code provider", () => {
it("should return claude-code model with supportsImages disabled", () => {
mockUseRouterModels.mockReturnValue({
data: {
openrouter: {},
requesty: {},
glama: {},
unbound: {},
litellm: {},
},
isLoading: false,
isError: false,
} as any)
mockUseOpenRouterModelProviders.mockReturnValue({
data: {},
isLoading: false,
isError: false,
} as any)
const apiConfiguration: ProviderSettings = {
apiProvider: "claude-code",
apiModelId: "claude-sonnet-4-20250514",
}
const wrapper = createWrapper()
const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper })
expect(result.current.provider).toBe("claude-code")
expect(result.current.id).toBe("claude-sonnet-4-20250514")
expect(result.current.info).toBeDefined()
expect(result.current.info?.supportsImages).toBe(false)
expect(result.current.info?.supportsPromptCache).toBe(true) // Claude Code now supports prompt cache
// Verify it inherits other properties from anthropic models
expect(result.current.info?.maxTokens).toBe(64_000)
expect(result.current.info?.contextWindow).toBe(200_000)
expect(result.current.info?.supportsComputerUse).toBe(true)
})
it("should use default claude-code model when no modelId is specified", () => {
mockUseRouterModels.mockReturnValue({
data: {
openrouter: {},
requesty: {},
glama: {},
unbound: {},
litellm: {},
},
isLoading: false,
isError: false,
} as any)
mockUseOpenRouterModelProviders.mockReturnValue({
data: {},
isLoading: false,
isError: false,
} as any)
const apiConfiguration: ProviderSettings = {
apiProvider: "claude-code",
}
const wrapper = createWrapper()
const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper })
expect(result.current.provider).toBe("claude-code")
expect(result.current.id).toBe("claude-sonnet-4-20250514") // Default model
expect(result.current.info).toBeDefined()
expect(result.current.info?.supportsImages).toBe(false)
})
})
})

View file

@ -0,0 +1,167 @@
// npx vitest run src/components/ui/hooks/__tests__/useSelectedModel.spec.ts
import { renderHook } from "@testing-library/react"
import { describe, it, expect, vi } from "vitest"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ReactNode } from "react"
import { useSelectedModel } from "../useSelectedModel"
// Mock the router models hooks
vi.mock("../useRouterModels", () => ({
useRouterModels: () => ({
data: {
openrouter: {},
requesty: {},
glama: {},
unbound: {},
litellm: {},
ollama: {},
lmstudio: {},
},
isLoading: false,
isError: false,
}),
}))
vi.mock("../useOpenRouterModelProviders", () => ({
useOpenRouterModelProviders: () => ({
data: {},
isLoading: false,
isError: false,
}),
}))
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
describe("useSelectedModel", () => {
describe("vertex provider", () => {
it("should return custom model when vertexCustomModelId is provided", () => {
const { result } = renderHook(
() =>
useSelectedModel({
apiProvider: "vertex",
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexCustomModelId: "claude-sonnet-4@20250514",
}),
{ wrapper: createWrapper() },
)
expect(result.current.id).toBe("claude-sonnet-4@20250514")
expect(result.current.info).toBeDefined()
expect(result.current.info?.maxTokens).toBe(8192) // Default model info
})
it("should trim whitespace from custom model ID", () => {
const { result } = renderHook(
() =>
useSelectedModel({
apiProvider: "vertex",
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexCustomModelId: " claude-sonnet-4@20250514 ",
}),
{ wrapper: createWrapper() },
)
expect(result.current.id).toBe("claude-sonnet-4@20250514")
})
it("should fall back to predefined model when custom model is empty", () => {
const { result } = renderHook(
() =>
useSelectedModel({
apiProvider: "vertex",
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexCustomModelId: "",
}),
{ wrapper: createWrapper() },
)
expect(result.current.id).toBe("claude-3-5-sonnet-v2@20241022")
})
it("should fall back to predefined model when custom model is only whitespace", () => {
const { result } = renderHook(
() =>
useSelectedModel({
apiProvider: "vertex",
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexCustomModelId: " ",
}),
{ wrapper: createWrapper() },
)
expect(result.current.id).toBe("claude-3-5-sonnet-v2@20241022")
})
it("should fall back to default model when no model is specified", () => {
const { result } = renderHook(
() =>
useSelectedModel({
apiProvider: "vertex",
}),
{ wrapper: createWrapper() },
)
expect(result.current.id).toBe("claude-sonnet-4@20250514") // Default vertex model
})
it("should prioritize custom model over predefined model", () => {
const { result } = renderHook(
() =>
useSelectedModel({
apiProvider: "vertex",
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexCustomModelId: "claude-sonnet-4@20250514",
}),
{ wrapper: createWrapper() },
)
expect(result.current.id).toBe("claude-sonnet-4@20250514")
expect(result.current.id).not.toBe("claude-3-5-sonnet-v2@20241022")
})
it("should handle custom model without vertexCustomModelId field", () => {
const { result } = renderHook(
() =>
useSelectedModel({
apiProvider: "vertex",
apiModelId: "claude-3-5-sonnet-v2@20241022",
}),
{ wrapper: createWrapper() },
)
expect(result.current.id).toBe("claude-3-5-sonnet-v2@20241022")
})
it("should use default model info for custom models", () => {
const { result } = renderHook(
() =>
useSelectedModel({
apiProvider: "vertex",
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexCustomModelId: "custom-model@latest",
}),
{ wrapper: createWrapper() },
)
expect(result.current.id).toBe("custom-model@latest")
// Should use default model info as fallback
expect(result.current.info?.maxTokens).toBe(8192)
expect(result.current.info?.contextWindow).toBe(200_000)
expect(result.current.info?.supportsImages).toBe(true)
expect(result.current.info?.supportsPromptCache).toBe(true)
})
})
})

View file

@ -148,6 +148,18 @@ function getSelectedModel({
return { id, info }
}
case "vertex": {
// Check if a custom model is specified
const customModelId = apiConfiguration.vertexCustomModelId
if (customModelId && customModelId.trim()) {
// For custom models, use default model info as fallback
const defaultInfo = vertexModels[vertexDefaultModelId]
return {
id: customModelId.trim(),
info: defaultInfo,
}
}
// Use predefined models
const id = apiConfiguration.apiModelId ?? vertexDefaultModelId
const info = vertexModels[id as keyof typeof vertexModels]
return { id, info }

View file

@ -321,7 +321,11 @@
"learnMore": "Learn more about provider routing"
}
},
"customModel": {
"customModel": "Custom Model",
"vertex": {
"customModelDescription": "Enter a custom Vertex AI model name (e.g., claude-sonnet-4@20250514). This allows you to use models not listed in the dropdown or specify exact model versions required by your Vertex AI setup."
},
"openaiCustomModel": {
"capabilities": "Configure the capabilities and pricing for your custom OpenAI-compatible model. Be careful when specifying the model capabilities, as they can affect how Roo Code performs.",
"maxTokens": {
"label": "Max Output Tokens",