mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
d0452d0d5c
commit
56c872d8e4
8 changed files with 354 additions and 445 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue