From 4bf8dd3aaf1217916b2ce8a6f9168fa7633a26f8 Mon Sep 17 00:00:00 2001 From: KeelTrace <218190424+keeltrace@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:14:07 -0700 Subject: [PATCH] feat(settings): select supported LLM providers (#16772) Co-authored-by: Juan Pedro Michelini Jorge --- .../provider-connection-modal.tsx | 103 +++++++++++++++--- .../provider-connections-manager.test.tsx | 85 +++++++++++++++ ...k-llm-provider-connection-selector.spec.ts | 49 +++++++++ 3 files changed, 224 insertions(+), 13 deletions(-) create mode 100644 tests/e2e/mock-llm/settings/mock-llm-provider-connection-selector.spec.ts diff --git a/src/components/features/settings/llm-profiles/provider-connection-modal.tsx b/src/components/features/settings/llm-profiles/provider-connection-modal.tsx index 606697e23a..5f8847b6b7 100644 --- a/src/components/features/settings/llm-profiles/provider-connection-modal.tsx +++ b/src/components/features/settings/llm-profiles/provider-connection-modal.tsx @@ -1,4 +1,9 @@ import { useEffect, useRef, useState } from "react"; +import { + Autocomplete, + AutocompleteItem, + AutocompleteSection, +} from "@heroui/react"; import { useTranslation } from "react-i18next"; import { BrandButton } from "#/components/features/settings/brand-button"; import { SettingsInput } from "#/components/features/settings/settings-input"; @@ -14,6 +19,10 @@ import { } from "#/utils/custom-toast-handlers"; import { getApiErrorMessage } from "#/utils/api-error-message"; import { I18nKey } from "#/i18n/declaration"; +import { useSearchProviders } from "#/hooks/query/use-search-providers"; +import { mapProvider } from "#/utils/map-provider"; +import { formControlSettingsFieldClassName } from "#/utils/form-control-classes"; +import { heroUiAutocompleteSelectorButtonClassName } from "#/ui/combobox-caret"; const DEFAULT_PROVIDER = "custom"; @@ -43,10 +52,11 @@ export function ProviderConnectionModal({ const { t } = useTranslation("openhands"); const createConnection = useCreateProviderConnection(); const updateConnection = useUpdateProviderConnection(); + const { data: providers = [] } = useSearchProviders(); const firstFieldRef = useRef(null); const [displayName, setDisplayName] = useState(""); - const [provider, setProvider] = useState(DEFAULT_PROVIDER); + const [provider, setProvider] = useState(null); const [apiKey, setApiKey] = useState(""); const [baseUrl, setBaseUrl] = useState(""); @@ -55,7 +65,7 @@ export function ProviderConnectionModal({ useEffect(() => { setDisplayName(connection?.display_name ?? ""); - setProvider(connection?.provider ?? DEFAULT_PROVIDER); + setProvider(connection?.provider ?? (isCreate ? null : DEFAULT_PROVIDER)); setBaseUrl(connection?.base_url ?? ""); setApiKey(""); }, [connection, isCreate]); @@ -64,7 +74,15 @@ export function ProviderConnectionModal({ const trimmedName = displayName.trim(); const trimmedKey = apiKey.trim(); // On create the key is required; on edit an empty key means "leave unchanged". - const isValid = Boolean(trimmedName) && (!isCreate || Boolean(trimmedKey)); + const isValid = + Boolean(trimmedName) && + Boolean(provider?.trim()) && + (!isCreate || Boolean(trimmedKey)); + + const verifiedProviders = providers.filter((candidate) => candidate.verified); + const unverifiedProviders = providers.filter( + (candidate) => !candidate.verified, + ); if (!isOpen) return null; @@ -80,7 +98,7 @@ export function ProviderConnectionModal({ if (isCreate) { const created = await createConnection.mutateAsync({ display_name: trimmedName, - provider: provider.trim() || DEFAULT_PROVIDER, + provider: provider?.trim() || DEFAULT_PROVIDER, api_key: trimmedKey, base_url: trimmedBaseUrl || null, }); @@ -95,7 +113,7 @@ export function ProviderConnectionModal({ id: connection.id, request: { display_name: trimmedName, - provider: provider.trim() || DEFAULT_PROVIDER, + provider: provider?.trim() || DEFAULT_PROVIDER, base_url: trimmedBaseUrl || null, // Omit the key entirely when left blank so the stored key is kept. ...(trimmedKey ? { api_key: trimmedKey } : {}), @@ -163,14 +181,73 @@ export function ProviderConnectionModal({ onChange={setDisplayName} required /> - + {isCreate ? ( +
+ + setProvider(key?.toString() ?? null)} + classNames={{ + popoverContent: + "bg-content1 rounded-xl border border-[var(--oh-border)]", + selectorButton: heroUiAutocompleteSelectorButtonClassName, + }} + selectorButtonProps={{ disableRipple: true }} + inputProps={{ + classNames: { + inputWrapper: formControlSettingsFieldClassName, + }, + }} + > + + {verifiedProviders.map((candidate) => ( + + {mapProvider(candidate.name)} + + ))} + + {unverifiedProviders.length > 0 ? ( + + {unverifiedProviders.map((candidate) => ( + + {mapProvider(candidate.name)} + + ))} + + ) : null} + +
+ ) : ( + + )} ({ displaySuccessToast, })); +vi.mock("#/hooks/query/use-search-providers", () => ({ + useSearchProviders: () => ({ + data: [ + { name: "openai", verified: true }, + { name: "anthropic", verified: true }, + { name: "openhands", verified: true }, + { name: "azure", verified: false }, + ], + }), +})); + const renderWith = (ui: React.ReactElement) => renderWithProviders(ui); const connection: ProviderConnection = { @@ -51,6 +63,79 @@ describe("ProviderConnectionsManager", () => { ).toBeInTheDocument(); }); + it("shows supported providers in the create-connection selector", async () => { + const user = userEvent.setup(); + + renderWith( + , + ); + + await user.click(screen.getByTestId("add-provider-connection")); + + const providerSelector = screen.getByRole("combobox", { + name: /provider/i, + }); + await user.click(providerSelector); + + expect(screen.getByText("OpenAI")).toBeInTheDocument(); + expect(screen.getByText("Anthropic")).toBeInTheDocument(); + expect(screen.getByText("OpenHands")).toBeInTheDocument(); + expect(screen.getByText("Azure")).toBeInTheDocument(); + + await user.click(screen.getByText("Anthropic")); + expect(providerSelector).toHaveValue("Anthropic"); + }); + + it("submits the raw provider id when creating a connection", async () => { + const user = userEvent.setup(); + const createSpy = vi + .spyOn(ProviderConnectionsService, "create") + .mockResolvedValue({ + ...connection, + id: "conn-anthropic", + display_name: "My Anthropic", + provider: "anthropic", + }); + + renderWith( + , + ); + + await user.click(screen.getByTestId("add-provider-connection")); + await user.type( + screen.getByTestId("provider-connection-name-input"), + "My Anthropic", + ); + + const providerSelector = screen.getByRole("combobox", { + name: /provider/i, + }); + await user.click(providerSelector); + await user.click(screen.getByTestId("provider-item-anthropic")); + + await user.type( + screen.getByTestId("provider-connection-api-key-input"), + "test-key", + ); + await user.click(screen.getByTestId("provider-connection-submit")); + + await waitFor(() => { + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ provider: "anthropic" }), + ); + }); + }); + it("lists a row per connection with its display name and provider", () => { renderWith( { + test.beforeEach(async ({ page }) => { + await seedLocalStorage(page); + }); + + test("selects a supported provider in Add provider", async ({ page }) => { + test.setTimeout(120_000); + + await routeSessionApiKey(page); + await page.goto("/settings/llm", { waitUntil: "domcontentloaded" }); + await dismissAnalyticsModal(page); + await waitForTestId(page, "add-provider-connection"); + + await page.getByTestId("add-provider-connection").click(); + + const modal = page.getByTestId("provider-connection-modal"); + await expect(modal).toBeVisible(); + + const providerSelector = modal.getByRole("combobox", { + name: /provider/i, + }); + await expect(providerSelector).toBeVisible(); + + await providerSelector.click(); + + const openAI = page.getByTestId("provider-item-openai"); + await expect(openAI).toBeVisible(); + + // Leave the dropdown open long enough for the recorded animation + // to clearly demonstrate supported-provider choices. + await page.waitForTimeout(1_200); + + await openAI.click(); + await expect(providerSelector).toHaveValue("OpenAI"); + + // Hold the selected state for the final frames of the evidence. + await page.waitForTimeout(1_200); + }); +});