feat: Hide static providers with no models from provider list (#7392)

This commit is contained in:
Daniel 2025-08-25 16:17:07 -05:00 • committed by GitHub
parent 2c82f4c950
commit c75f184022
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 317 additions and 2 deletions

View file

@ -379,11 +379,37 @@ const ApiOptions = ({
// Convert providers to SearchableSelect options
const providerOptions = useMemo(() => {
return filterProviders(PROVIDERS, organizationAllowList).map(({ value, label }) => ({
// First filter by organization allow list
const allowedProviders = filterProviders(PROVIDERS, organizationAllowList)
// Then filter out static providers that have no models (unless currently selected)
const providersWithModels = allowedProviders.filter(({ value }) => {
// Always show the currently selected provider to avoid breaking existing configurations
// Use apiConfiguration.apiProvider directly since that's what's actually selected
if (value === apiConfiguration.apiProvider) {
return true
}
// Check if this is a static provider (has models in MODELS_BY_PROVIDER)
const staticModels = MODELS_BY_PROVIDER[value as ProviderName]
// If it's a static provider, check if it has any models after filtering
if (staticModels) {
const filteredModels = filterModels(staticModels, value as ProviderName, organizationAllowList)
// Hide the provider if it has no models after filtering
return filteredModels && Object.keys(filteredModels).length > 0
}
// If it's a dynamic provider (not in MODELS_BY_PROVIDER), always show it
// to avoid race conditions with async model fetching
return true
})
return providersWithModels.map(({ value, label }) => ({
value,
label,
}))
}, [organizationAllowList])
}, [organizationAllowList, apiConfiguration.apiProvider])
return (
<div className="flex flex-col gap-3">

View file

@ -0,0 +1,289 @@
import { describe, it, expect, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import ApiOptions from "../ApiOptions"
import { MODELS_BY_PROVIDER, PROVIDERS } from "../constants"
import type { ProviderSettings } from "@roo-code/types"
import type { OrganizationAllowList } from "@roo/cloud"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel"
// Mock the extension state context
vi.mock("@src/context/ExtensionStateContext", () => ({
useExtensionState: vi.fn(() => ({
organizationAllowList: undefined,
cloudIsAuthenticated: false,
})),
}))
// Mock the translation hook
vi.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock vscode
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
// Mock the router models hook
vi.mock("@src/components/ui/hooks/useRouterModels", () => ({
useRouterModels: () => ({
data: null,
refetch: vi.fn(),
}),
}))
// Mock the selected model hook
vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({
useSelectedModel: vi.fn(() => ({
provider: "anthropic",
id: "claude-3-5-sonnet-20241022",
info: null,
})),
}))
// Mock the OpenRouter model providers hook
vi.mock("@src/components/ui/hooks/useOpenRouterModelProviders", () => ({
useOpenRouterModelProviders: () => ({
data: null,
}),
OPENROUTER_DEFAULT_PROVIDER_NAME: "Auto",
}))
// Mock the SearchableSelect component to capture the options passed to it
vi.mock("@src/components/ui", () => ({
SearchableSelect: ({ options, ...props }: any) => {
// Store the options in a data attribute for testing
return (
<div data-testid="searchable-select" data-options={JSON.stringify(options)} {...props}>
{options.map((opt: any) => (
<div key={opt.value} data-testid={`option-${opt.value}`}>
{opt.label}
</div>
))}
</div>
)
},
Select: ({ children }: any) => <div>{children}</div>,
SelectTrigger: ({ children }: any) => <div>{children}</div>,
SelectValue: ({ placeholder }: any) => <div>{placeholder}</div>,
SelectContent: ({ children }: any) => <div>{children}</div>,
SelectItem: ({ children, value }: any) => <div data-value={value}>{children}</div>,
Collapsible: ({ children }: any) => <div>{children}</div>,
CollapsibleTrigger: ({ children }: any) => <div>{children}</div>,
CollapsibleContent: ({ children }: any) => <div>{children}</div>,
Slider: ({ children, ...props }: any) => <div {...props}>{children}</div>,
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}))
describe("ApiOptions Provider Filtering", () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
const defaultProps = {
uriScheme: "vscode",
apiConfiguration: {
apiProvider: "anthropic",
apiKey: "test-key",
} as ProviderSettings,
setApiConfigurationField: vi.fn(),
fromWelcomeView: false,
errorMessage: undefined,
setErrorMessage: vi.fn(),
}
const renderWithProviders = (props = defaultProps) => {
return render(
<QueryClientProvider client={queryClient}>
<ApiOptions {...props} />
</QueryClientProvider>,
)
}
it("should show all providers when no organization allow list is provided", () => {
renderWithProviders()
const selectElement = screen.getByTestId("provider-select")
const options = JSON.parse(selectElement.getAttribute("data-options") || "[]")
// Should include both static and dynamic providers
const providerValues = options.map((opt: any) => opt.value)
expect(providerValues).toContain("anthropic") // static provider
expect(providerValues).toContain("openrouter") // dynamic provider
expect(providerValues).toContain("ollama") // dynamic provider
})
it("should hide static providers with empty models", () => {
// Mock MODELS_BY_PROVIDER to have an empty provider
const _originalModels = { ...MODELS_BY_PROVIDER }
;(MODELS_BY_PROVIDER as any).emptyProvider = {}
// Add the empty provider to PROVIDERS
PROVIDERS.push({ value: "emptyProvider", label: "Empty Provider" })
renderWithProviders()
const selectElement = screen.getByTestId("provider-select")
const options = JSON.parse(selectElement.getAttribute("data-options") || "[]")
const providerValues = options.map((opt: any) => opt.value)
// Should NOT include the empty static provider
expect(providerValues).not.toContain("emptyProvider")
// Cleanup
delete (MODELS_BY_PROVIDER as any).emptyProvider
PROVIDERS.pop()
})
it("should always show dynamic providers even if they have no models yet", () => {
renderWithProviders()
const selectElement = screen.getByTestId("provider-select")
const options = JSON.parse(selectElement.getAttribute("data-options") || "[]")
const providerValues = options.map((opt: any) => opt.value)
// Dynamic providers (not in MODELS_BY_PROVIDER) should always be shown
expect(providerValues).toContain("openrouter")
expect(providerValues).toContain("ollama")
expect(providerValues).toContain("lmstudio")
expect(providerValues).toContain("litellm")
expect(providerValues).toContain("glama")
expect(providerValues).toContain("unbound")
expect(providerValues).toContain("requesty")
expect(providerValues).toContain("io-intelligence")
})
it("should filter static providers based on organization allow list", () => {
// Create a mock organization allow list that only allows certain models
const allowList: OrganizationAllowList = {
allowAll: false,
providers: {
anthropic: {
allowAll: false,
models: ["claude-3-5-sonnet-20241022"], // Only allow one model
},
gemini: {
allowAll: false,
models: [], // No models allowed
},
openrouter: {
allowAll: true, // Dynamic provider with all models allowed
},
},
}
// Mock the extension state with the allow list
vi.mocked(useExtensionState).mockReturnValue({
organizationAllowList: allowList,
cloudIsAuthenticated: false,
} as any)
renderWithProviders()
const selectElement = screen.getByTestId("provider-select")
const options = JSON.parse(selectElement.getAttribute("data-options") || "[]")
const providerValues = options.map((opt: any) => opt.value)
// Should include anthropic (has allowed models)
expect(providerValues).toContain("anthropic")
// Should NOT include gemini (no allowed models)
expect(providerValues).not.toContain("gemini")
// Should include openrouter (dynamic provider)
expect(providerValues).toContain("openrouter")
// Should NOT include providers not in the allow list
expect(providerValues).not.toContain("openai-native")
expect(providerValues).not.toContain("mistral")
})
it("should show static provider when allowAll is true for that provider", () => {
const allowList: OrganizationAllowList = {
allowAll: false,
providers: {
anthropic: {
allowAll: true, // Allow all models for this provider
},
},
}
vi.mocked(useExtensionState).mockReturnValue({
organizationAllowList: allowList,
cloudIsAuthenticated: false,
} as any)
renderWithProviders()
const selectElement = screen.getByTestId("provider-select")
const options = JSON.parse(selectElement.getAttribute("data-options") || "[]")
const providerValues = options.map((opt: any) => opt.value)
// Should include anthropic since allowAll is true
expect(providerValues).toContain("anthropic")
})
it("should always show currently selected provider even if it has no models", () => {
// Add an empty static provider to test
;(MODELS_BY_PROVIDER as any).testEmptyProvider = {}
// Add the provider to the PROVIDERS list
PROVIDERS.push({ value: "testEmptyProvider", label: "Test Empty Provider" })
// Create a mock organization allow list that allows the provider but no models
const allowList: OrganizationAllowList = {
allowAll: false,
providers: {
testEmptyProvider: {
allowAll: true, // Allow the provider itself, but it has no models in MODELS_BY_PROVIDER
},
anthropic: {
allowAll: true, // Allow anthropic for comparison
},
},
}
vi.mocked(useExtensionState).mockReturnValue({
organizationAllowList: allowList,
cloudIsAuthenticated: false,
} as any)
// Mock the selected model hook to return testEmptyProvider as the selected provider
;(useSelectedModel as any).mockReturnValue({
provider: "testEmptyProvider",
id: undefined,
info: null,
})
// Render with testEmptyProvider as the selected provider
const props = {
...defaultProps,
apiConfiguration: {
...defaultProps.apiConfiguration,
apiProvider: "testEmptyProvider" as any,
} as ProviderSettings,
}
renderWithProviders(props)
const selectElement = screen.getByTestId("provider-select")
const options = JSON.parse(selectElement.getAttribute("data-options") || "[]")
const providerValues = options.map((opt: any) => opt.value)
// Should include testEmptyProvider even though it has no models (empty object in MODELS_BY_PROVIDER), because it's currently selected
expect(providerValues).toContain("testEmptyProvider")
// Should also include anthropic since it has allowAll: true
expect(providerValues).toContain("anthropic")
// Cleanup
delete (MODELS_BY_PROVIDER as any).testEmptyProvider
PROVIDERS.pop()
})
})