Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 114 additions & 4 deletions webview-ui/src/components/settings/__tests__/ModelPicker.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,40 @@
// npx vitest src/components/settings/__tests__/ModelPicker.spec.tsx

import { screen, fireEvent, renderWithExtensionState } from "@/utils/test-utils"
import { act } from "react"
import { act, type ReactNode } from "react"
import { QueryClient } from "@tanstack/react-query"
import { type Mock } from "vitest"

import { ModelInfo, providerIdentifiers } from "@roo-code/types"
import {
litellmDefaultModelId,
type ModelInfo,
type ProviderSettings,
type RouterModels,
providerIdentifiers,
} from "@roo-code/types"

import { ModelPicker } from "../ModelPicker"
import { useRouterModels } from "@src/components/ui/hooks/useRouterModels"

type SetApiConfigurationField = <K extends keyof ProviderSettings>(
field: K,
value: ProviderSettings[K],
isUserAction?: boolean,
) => void

// useRouterModels returns a react-query observable result; these tests only need the stable state fields.
const createRouterModelsResult = (data: Partial<RouterModels>): ReturnType<typeof useRouterModels> =>
({ data, isLoading: false, isError: false }) as ReturnType<typeof useRouterModels>

vi.mock("@src/context/ExtensionStateContext", () => ({
ExtensionStateContextProvider: ({ children }: any) => children,
ExtensionStateContextProvider: ({ children }: { children: ReactNode }) => children,
useExtensionState: vi.fn(),
}))

vi.mock("@src/components/ui/hooks/useRouterModels")

const mockUseRouterModels = useRouterModels as Mock<typeof useRouterModels>

Element.prototype.scrollIntoView = vi.fn()

describe("ModelPicker", () => {
Expand All @@ -34,8 +56,9 @@ describe("ModelPicker", () => {
model2: { name: "Model 2", description: "Test model 2", ...modelInfo },
}

const apiConfiguration: ProviderSettings = {}
const defaultProps = {
apiConfiguration: {},
apiConfiguration,
defaultModelId: "model1",
modelIdKey: "openRouterModelId" as const,
serviceName: "Test Service",
Expand All @@ -55,6 +78,8 @@ describe("ModelPicker", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
// Default: no router models available. Provider-specific tests override per test.
mockUseRouterModels.mockReturnValue(createRouterModelsResult({}))
})

afterEach(() => {
Expand Down Expand Up @@ -254,4 +279,89 @@ describe("ModelPicker", () => {
expect(screen.getByTestId("automatic-fetch-hint")).toBeInTheDocument()
})
})

describe("LiteLLM custom model selection", () => {
const litellmModels: Record<string, ModelInfo> = {
"gpt-4o-mini": { description: "LiteLLM proxy model", ...modelInfo },
}

const renderLiteLLMPicker = (apiConfiguration: ProviderSettings, setField: SetApiConfigurationField) =>
renderWithExtensionState(
<ModelPicker
apiConfiguration={apiConfiguration}
defaultModelId={litellmDefaultModelId}
models={litellmModels}
modelIdKey="litellmModelId"
serviceName="LiteLLM"
serviceUrl="https://docs.litellm.ai/"
setApiConfigurationField={setField}
organizationAllowList={{ allowAll: true, providers: {} }}
/>,
{ queryClient },
)

beforeEach(() => {
mockUseRouterModels.mockReturnValue(createRouterModelsResult({ litellm: litellmModels }))
})

it("keeps a custom model ID in the picker instead of reverting to the default", async () => {
// Regression: on the LiteLLM settings screen the user could not change the
// model ID to a value absent from the fetched /models list -- the picker
// silently reverted to the hardcoded default model after the selection.
const customModelId = "my-litellm-alias"
let apiConfiguration: ProviderSettings = { apiProvider: providerIdentifiers.litellm }
const setField = vi.fn(function <K extends keyof ProviderSettings>(field: K, value: ProviderSettings[K]) {
apiConfiguration = { ...apiConfiguration, [field]: value }
})

const { rerender } = await act(async () => {
return renderLiteLLMPicker(apiConfiguration, setField)
})

// Before any selection the picker shows the provider default.
expect(screen.getByTestId("model-picker-button")).toHaveTextContent(litellmDefaultModelId)

// Open the popover and type a model ID that is not in the fetched list.
await act(async () => {
fireEvent.click(screen.getByTestId("model-picker-button"))
})
await act(async () => {
vi.advanceTimersByTime(100)
})
await act(async () => {
fireEvent.input(screen.getByTestId("model-input"), { target: { value: customModelId } })
})
await act(async () => {
vi.advanceTimersByTime(100)
})
await act(async () => {
fireEvent.click(screen.getByTestId("use-custom-model"))
})
await act(async () => {
vi.advanceTimersByTime(100)
})

expect(setField).toHaveBeenCalledWith("litellmModelId", customModelId)

// Re-render with the updated configuration (as SettingsView does after the
// setter runs) and assert the selection is kept, not reset to the default.
await act(async () => {
rerender(
<ModelPicker
apiConfiguration={apiConfiguration}
defaultModelId={litellmDefaultModelId}
models={litellmModels}
modelIdKey="litellmModelId"
serviceName="LiteLLM"
serviceUrl="https://docs.litellm.ai/"
setApiConfigurationField={setField}
organizationAllowList={{ allowAll: true, providers: {} }}
/>,
)
})

expect(screen.getByTestId("model-picker-button")).toHaveTextContent(customModelId)
expect(screen.getByTestId("model-picker-button")).not.toHaveTextContent(litellmDefaultModelId)
})
})
})
Loading
Loading